Architecture
ProbeGlass is one process: an HTTP server, a scheduler, and a SQLite file. This page explains how the pieces fit together and why they were cut this way. It is written for someone about to change the code, not for an end user.
Shape of the process
cmd/probeglass flags, wiring, graceful shutdown
├── internal/config defaults → YAML → environment → flags → validate
├── internal/store one *sql.DB, schema, queries, rollups, retention
├── internal/doctor read-only inspection, for `probeglass doctor`
├── internal/scheduler one goroutine per enabled sensor
│ ├── internal/sensor http | tcp | ping | dns
│ ├── internal/notify telegram, discord, matrix, ntfy, slack, webhook
│ └── internal/event in-process pub/sub → SSE
└── internal/api HTTP handlers, auth, CSRF, SSE
└── internal/web embed.FS with index.html, app.js, i18n.js, style.css
Startup is deliberately linear: load configuration, open the store, apply the declarative sensors, build the scheduler and the dispatcher, build the API server, then serve until the signal arrives. Shutdown cancels the root context, waits for the scheduler's goroutines, and closes the database. There is no dependency injection framework and no service registry, because there are eight components and their order is fixed.
Storage
Everything is in one SQLite file, opened with modernc.org/sqlite — a pure-Go
translation of SQLite, so CGO_ENABLED=0 holds and the image needs no C toolchain
to build for a Pi. The connection pool is pinned to a single connection
(SetMaxOpenConns(1)): the writes are tiny, and correctness and freedom from lock
contention are worth more than concurrency on a homelab workload. WAL,
busy_timeout=5000, foreign_keys=ON, and synchronous=NORMAL are set on open.
sensors one row per sensor, options as a JSON blob
channels one row per alert channel, config as a JSON blob
sensor_channels many-to-many, both sides ON DELETE CASCADE
results raw probe results, indexed by (sensor_id, ts)
results_hourly bucket = "2026-10-08T13:00:00.000000000Z"
results_daily bucket = "2026-10-08"
incidents started_at, resolved_at, cause
settings key/value: password hash, API key, session secret, UI settings
The single-connection decision has one sharp edge worth knowing before you touch a
query: a *sql.Rows that is never closed holds that one connection forever, and
every other request in the process then blocks behind it. Every query in
internal/store closes its rows, most of them via defer, and that is not
optional style.
Two schema decisions carry weight beyond their size:
Timestamps are fixed-width UTC strings. They are written as
2006-01-02T15:04:05.000000000Z, never RFC3339Nano, because the latter drops
trailing zeros and …:00.5Z then sorts before …:00Z. Since every range query
is a lexicographic BETWEEN on ts, the format is a correctness requirement, not
a style choice. store_test.go pins it down, including a test that asserts the
RFC3339Nano form sorts wrong.
Buckets are derived from the timestamp prefix. An hourly bucket is
substr(ts,1,13) || ':00:00.000000000Z', a daily one substr(ts,1,10). No
boundary arithmetic, no timezone surprises, and the rollup statements can be
expressed in pure SQL.
Three resolutions, one answer
A range query selects exactly one resolution — raw if the window fits in the
raw window, hourly if it fits in the hourly window, daily otherwise — and reads
only from that table. This is the property that keeps the numbers honest: if two
resolutions could contribute to one chart, samples inside the overlap would be
counted twice and uptime would quietly drift above 100%. A test
(TestSeriesPicksOneResolution) probes exactly this.
Rollups are idempotent. The insert is an
ON CONFLICT DO UPDATE SET samples = excluded.samples, so a repeated pass rewrites
the same bucket rather than accumulating it. Only complete hours and days are
rolled up — the boundary is the start of the current hour or day — which is why the
hour you are currently in is never summarised half-finished. Raw pruning deletes
rows below the raw window; the rollups they fed stay.
Charts therefore render a gap as a gap. A bucket with no samples is returned as empty rather than as a zero, because "we did not probe" and "the probe failed" are different facts and a monitoring tool that conflates them is worse than useless.
Sensors
internal/sensor defines one interface:
type Prober interface {
Type() model.SensorType
Validate(model.Sensor) error
Run(context.Context, model.Sensor) (Outcome, error)
}
Adding a sensor type means writing one file with those three methods and registering
it. Options is a map[string]any with typed accessors (Str, Int, Bool) that
return defaults for anything missing or of the wrong type, so a sensor saved by an
older version keeps working — a count that arrives as a string falls back to the
default rather than becoming zero.
Each engine validates itself, and the same validation runs on the draft from the UI
(POST /api/sensors/validate), so the form can tell you the target is not host:port
before you save it rather than after.
Two engine details worth knowing:
- Ping tries an unprivileged datagram ICMP socket first, then a raw socket, and reports precisely which one failed and why when neither works. Silent ping failure is the most confusing thing about containerised monitoring; a sensor that cannot ping says so.
- DNS builds its own queries with
golang.org/x/net/dns/dnsmessageinstead of leaning on the system resolver, so it can point at a specific server and inspect the actual records rather than whatever the resolver decided to return.
Scheduler and incident lifecycle
One goroutine per enabled sensor, each on its own ticker with a little jitter so that
thirty sensors configured at 60s do not fire in the same millisecond. The scheduler
does not own the sensor list: Reconcile diffs the stored sensors against the running
loops and starts, restarts, or stops them, and it is called after any change made
over the API. A sensor edited in the UI therefore changes behaviour without a restart.
The loops, though, belong to Start: Reconcile reaches the scheduler from inside an
HTTP request, and a loop parented to that request would die with the response, halfway
through its first probe. Before the daemon has started its scheduler, Reconcile
rebuilds the sensors' runtime state and starts nothing.
Per sensor, the runtime state is: the current status, the consecutive-failure count, the open incident, and the time of the last alert. On startup this is rebuilt from the database — the running failure streak and any still-open incident included — so a container restart neither loses an outage nor re-announces one that is already known.
The state machine is small and lives in one function:
success → fail streak resets; if an incident was open, close it and alert "up"
failure → fail streak grows; when streak == retries, open an incident and alert "down"
while an incident stays open, alert again every reminder_every
The transition and its incident row are written while the mutex is held; logging, event fan-out, and alert delivery happen after it is released. Notifications never run inside the lock, so a slow Telegram API call cannot stall every sensor in the process.
Alert delivery fans out to a sensor's channels in parallel goroutines, each with up
to three attempts and a linear backoff; a 429 or a 5xx is retried, other 4xx
responses are not, because a bad token will not fix itself. A channel that keeps
failing has its error recorded and surfaced in the UI, and never suppresses the
others. ProbeNow, which backs the dashboard's "Run now" button, deliberately
bypasses all of this: a manual probe is informational and never opens an incident or
fires an alert.
API and authentication
net/http's ServeMux with method-and-pattern routing (GET /api/sensors/{id}) — no
router dependency, and Go 1.22+ handles the path variables.
internal/api is split by concern: server.go builds the routing tree,
handlers.go holds the endpoints, auth.go the session and API-key logic, mask.go
the secret redaction. Handlers are thin: parse and validate the body, call the store,
write JSON. Validation errors come back as {"error": "..."} with a 4xx status and
are shown verbatim in the UI, so the message a user sees is the one the server wrote.
Updates are partial by contract: every field of a request body is a pointer, so
"absent" is distinguishable from "empty", and the handler overlays only what the
client sent on top of the stored row. A body carrying {"target": "..."} changes the
target and nothing else; an explicit "" or [] clears a field. The alternative —
treating an absent field as an empty one — silently blanks the name, clears the
group, and resets the interval to its default.
Authentication supports two paths:
- Session cookie —
probeglass_session, value"<expiry>.<hmac>"signed with a secret generated on first start,HttpOnly,SameSite=Strict, and deliberately withoutSecure, because homelab installs are usually plain HTTP on the LAN. The code comments say so; the test asserts it. - API key — a generated key sent as
X-API-KeyorAuthorization: Bearer, for scripts, cron jobs, and Home Assistant.
Cookie-authenticated writes are additionally checked against Origin (falling back
to Referer) plus SameSite=Strict, which together stop a page on another site from
driving the API. Requests with no Origin at all — curl, scripts — pass, because
they carry no ambient cookie and are already covered by the API key.
Passwords are bcrypt at cost 12. Login attempts are limited to five per minute in
memory. /api/setup runs once: a fresh database has no password, the dashboard shows
a setup screen, and after that the endpoint refuses. A password in
PROBEGLASS_ADMIN_PASSWORD bootstraps the first one and is then ignored.
Secrets never leave the server. internal/api/mask.go replaces every field the
channel definition marks Secret: true with a marker on the way out, and a request
that sends the marker back keeps the stored value instead of overwriting it with the
literal marker. The Telegram sender strips the token from error messages before they
reach a log line or the UI.
Logging and diagnostics
Every request produces exactly one log line: info as msg="http request" with the
method, path, status and duration — or, when the status is 500 or worse, error as
msg="http request failed" with the same fields plus message (what the client was
told) and error (what actually went wrong, which the client never sees). The
invariant is enforced by the response writer rather than by discipline at the call
sites: the recorder the middleware already wraps around every handler carries the
failure up from wherever it was written, so a handler added later that forgets to log
is still logged. Two things bypass it deliberately — a panic, which net/http recovers
into its own ErrorLog, and a background error that does not spoil the response (a
reconcile failing after an already-successful 201), which is logged where it happens.
A request that took longer than ten seconds logs a separate warn on top, because
"slow" and "failed" are different statements.
internal/doctor is the other half of that: what the log says about one request, doctor
says about the whole installation. Its two rules shape the code — it changes nothing
and it sends no alert — which is why it opens the database through store.OpenReadOnly
with a DSN of its own. A journal_mode pragma is a write, so reusing store.Open's DSN
would make a read-only open fail outright rather than merely refuse to write. The same
reasoning produced config.Resolve beside config.Load: a diagnostic has to keep
naming the data directory precisely when the configuration file is the thing that is
broken, and Load's job is to refuse to start.
Live updates
internal/event is a tiny pub/sub broker: publishers never block, and a subscriber
whose buffer is full drops the event rather than stalling the scheduler.
/api/events forwards the stream to the browser as SSE. The dashboard is eventually
consistent by design — every client also fetches /api/summary when it connects — so
a dropped event costs a stale row for a moment, never a wrong number.
Frontend
No build step, no framework, no CDN. internal/web/assets holds index.html,
app.js, charts.js, i18n.js, watchdog.js, and style.css as plain ES modules,
embedded with embed.FS. Editing a .js file and reloading the page is the whole
development loop, and the image ships exactly the bytes in the repository.
app.js renders from state and refetches on SSE events; charts.js draws the
sparklines and response-time charts on <canvas> without a charting library. Charts
are drawn for the theme in use and redrawn when it changes, so they stay readable in
high-contrast and dark modes.
Two conventions the frontend holds to:
No browser storage. No localStorage, no sessionStorage. The session is a
cookie the server owns, and every other preference is state the server can describe.
That makes "which settings am I actually running?" a question with exactly one
answer — and it means a dashboard that is a pure function of /api/summary plus the
event stream.
No silent hang. Every API call carries a 15-second deadline and reports a
timeout message rather than leaving a spinner. boot() is wrapped so that a failure
lands on an error panel with a Reload button. And watchdog.js — a separate module
that imports nothing — replaces the boot spinner with an explanation if app.js
itself failed to load, which is the one case no code inside app.js could report.
i18n.js is a message catalogue with a language list. English is the only dictionary
so far; adding a language means adding a dictionary and one entry, with no changes
elsewhere, because the markup never contains user-facing strings.
Packaging
Dockerfile is a two-stage build. The builder is pinned to
--platform=$BUILDPLATFORM and uses TARGETOS/TARGETARCH, so a multi-arch build
cross-compiles rather than emulating: the arm64 image for a Pi builds at native
speed. This requires BuildKit. The runtime stage is
gcr.io/distroless/static-debian12:nonroot, with USER 0:0 on top of it — no shell, no
package manager. It starts as root for exactly as long as it takes to hand the data
directory to PUID:PGID and become that user (internal/privdrop), because that
directory is a bind mount whose owner only root can change; after that it is
unprivileged and cannot go back. See
Privileges. Because there is no shell, the image's
HEALTHCHECK runs the binary itself (probeglass healthcheck), which makes a
GET /healthz and exits non-zero on failure.
Multi-arch publishing and the release archives are driven from
.github/workflows/release.yml, with the version, commit, and build date stamped
into internal/model via -ldflags, so probeglass version and /api/version
report exactly which build is running.
Testing
internal/store, internal/config, internal/sensor, internal/notify,
internal/scheduler, and internal/api all have tests; CI runs them with -race.
The sensor and notify tests exercise real sockets — a local httptest server, a real
TCP listener, a DNS stub built with dnsmessage — rather than mocking the network,
because the interesting failures are in the interaction with it. The API tests drive
a real httptest server with its own store and scheduler; the scheduler tests drive
the state machine a step at a time against a real store, which keeps the
incident-lifecycle assertions deterministic instead of dependent on a ticker.
The suite is not decoration. It has found, before any of them reached a user: a
template-source leak in notify that put raw Go template text into an alert, two
parsing gaps in config (integer intervals and day/week retention), a nil-context
panic in Reconcile, a partial update that detached every alert channel, and a
recovery alert that reported the outage length as zero because the start time was
read after the status timestamp had been overwritten.