1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
|
# telemetry
Host telemetry collection and a Prometheus/Loki/Grafana server, as two NixOS modules you
can deploy together or apart.
---
## ⚠️ This repository is vibe coded. Run it at your own risk.
Every line here — the modules, the collectors, the dashboard generators, the recording
rules, and this README — was written by an LLM in conversation. No human has reviewed it
line by line. There is no warranty of any kind. **Read it before you deploy it**, and pay
particular attention to the systemd hardening options, the firewall rules, and anything
touching raw block devices.
What *has* been checked:
- every dashboard query executed against a real Prometheus fed fixture data
- `promtool` over the recording rules and the generated scrape config
- `nix flake check`: both modules evaluate, and both dashboard generators run
- the collectors run against mock `smartctl`/`zpool`/`zfs`, and against a real NVMe drive
- the NVMe metric names the rules depend on, checked against a live `smartctl_exporter`
What has **not**:
- no panel has been rendered in a real Grafana
- no output has come from a real ZFS pool
- no real SAS self-test log has been parsed
Treat the numbers on the dashboards as unverified until you have sanity-checked them
against `zpool status` and `smartctl -x` yourself.
---
## What it is
The split is not cosmetic. The collectors read host hardware and kernel state — `/proc`,
`/sys`, SMART over raw block devices, ZFS ioctls — which a container has no view of. The
server reads none of that, so it can live wherever you like.
| module | contains | runs where |
|---|---|---|
| `nixosModules.collectors` | node + smartctl exporters, ZFS and SMART textfile collectors, Alloy | the host, necessarily |
| `nixosModules.server` | Prometheus, Loki, Grafana, optional Caddy | anywhere that can reach the collectors |
| `nixosModules.default` | both | one machine |
Grafana comes provisioned with four dashboards: Node Exporter Full (1860), Smartctl
Exporter (22604), and two generated here — ZFS Pools and Drive Health, a
CrystalDiskInfo-style per-drive view.
```nix
{
inputs.telemetry.url = "path:/home/timmy/docs/src/flakes/lgtm";
}
```
The examples below take `inputs` as a module argument, which means passing it through:
```nix
nixosConfigurations.poweredge = nixpkgs.lib.nixosSystem {
specialArgs = { inherit inputs; };
# ...
};
```
---
## Example: one machine
```nix
{ inputs, ... }:
{
imports = [ inputs.telemetry.nixosModules.default ];
services.telemetry = {
collectors.enable = true;
server = {
enable = true;
proxy.enable = true; # Grafana on :80
};
};
}
```
Grafana at `http://localhost`. `collectorHosts` defaults to `[ "localhost" ]`, and
Grafana's secret key is generated on first start into `/var/lib/grafana/secret-key`
(mode 0600, owned by `grafana`) — see below if you would rather supply it yourself.
## Example: web services in a container
The host runs the collectors; the container runs everything else. This is the intended
shape.
```nix
{ inputs, ... }:
{
imports = [ inputs.telemetry.nixosModules.collectors ];
services.telemetry.collectors = {
enable = true;
logs.lokiUrl = "http://metrics:3100"; # the container, by name
};
containers.metrics = {
autoStart = true;
privateNetwork = true;
hostBridge = "br-lan0";
localMacAddress = "02:00:00:00:00:06";
config = { ... }: {
imports = [ inputs.telemetry.nixosModules.server ];
networking.enableIPv6 = false;
networking.interfaces.eth0.useDHCP = true;
services.telemetry.server = {
enable = true;
collectorHosts = [ "poweredge" ]; # the host above
proxy.enable = true;
};
system.stateVersion = "26.05"; # the module does not set this for you
};
};
}
```
Two names have to resolve for this to work: the container must be able to look up
`poweredge`, and the host must be able to look up `metrics`. With `hostBridge` plus DHCP
on the LAN that is your router's job; otherwise use IP addresses in both places.
## Example: several machines
List them, and import the collectors module on each:
```nix
# in the container
services.telemetry.server.collectorHosts = [ "poweredge" "optiplex" ];
```
```nix
# on optiplex
{ inputs, ... }:
{
imports = [ inputs.telemetry.nixosModules.collectors ];
services.telemetry.collectors = {
enable = true;
logs.lokiUrl = "http://metrics:3100";
};
}
```
You get **one set of dashboards with a Host dropdown**, not a set per machine. Pick a
host and the whole page swaps to it.
| dashboard | picker(s) |
|---|---|
| Drive Health | **Host** → SATA drive / SAS drive / NVMe drive |
| ZFS Pools | **Host** → Pool |
| Node Exporter Full | Job → Nodename → Instance |
| Smartctl Exporter | Node |
Dependent dropdowns repopulate on switch, so you never see poweredge's `tank` while
viewing optiplex. Hosts appear and disappear on their own — the pickers are
`label_values(..., host)`, so a machine shows up as soon as Prometheus scrapes it.
The Host picker is **single-select with no "All"** on purpose: drive and pool names are
not unique across a fleet, and two hosts at once would make `max by (device)` silently
merge two different physical drives into one plausible-looking row.
## Example: turning things off
```nix
services.telemetry = {
collectors = {
enable = true;
zfs.enable = false; # no ZFS on this box
logs.enable = false; # no journal shipping
node.extraCollectors = [ "interrupts" ]; # fills 1860's IRQ panel; high cardinality
};
server = {
enable = true;
loki.enable = false;
grafana.dashboards.zfs-pools = false;
grafana.extraDashboards.mine = ./my-dashboard.json;
prometheus.extraScrapeConfigs = [{
job_name = "router";
static_configs = [{ targets = [ "router:9100" ]; }];
}];
};
};
```
Ports live in `services.telemetry.ports` (`node`, `smartctl`, `loki`, `prometheus`,
`grafana`) and are shared by both halves.
Jobs added through `extraScrapeConfigs` are passed to Prometheus untouched — they get no
`host` label, so they will not show up in the dashboards' Host picker. Add your own
`relabel_configs` if you want them to.
---
## Email alerts
`alerts.enable` brings up Alertmanager, points Prometheus at it, and loads a set of
alerting rules:
```nix
services.telemetry.server = {
enable = true;
collectorHosts = [ "poweredge" "optiplex" ];
alerts = {
enable = true;
to = [ "you@example.com" ];
from = "alerts@example.com";
smarthost = "smtp.fastmail.com:587";
username = "alerts@example.com";
passwordFile = config.sops.secrets.smtp-password.path;
};
};
```
`passwordFile` is handed to Alertmanager as `smtp_auth_password_file` and read at
runtime, so the password never enters the Nix store. Drop `username` and `passwordFile`
for an unauthenticated local relay.
### What it alerts on
Shipped in `rules/alerts.yml`, loaded unless you set `alerts.defaultRules = false`:
| | condition | severity |
|---|---|---|
| `DriveFailing` | SMART self-assessment failed, an attribute is below its threshold, uncorrectable errors, or an NVMe critical warning | critical |
| `DriveCaution` | reallocated/pending sectors, a non-empty SAS grown defect list, or 90%+ NVMe endurance used | warning |
| `SelfTestFailed` | last short or extended self-test did not pass | critical |
| `DriveOverheating` | above 55 °C for 30 minutes | warning |
| `PoolNotOnline` | pool is DEGRADED, FAULTED, … | critical |
| `PoolDataErrors` | errors ZFS could not repair from redundancy | critical |
| `PoolNearlyFull` | over 85% | warning |
| `ScrubOverdue` | no completed scrub in 45 days | warning |
| `SnapshotsOverdue` | auto-snapshot timer has missed two intervals | warning |
| `ExporterDown` | `up == 0` for 10 minutes | critical |
| `CollectorStale` | a `.prom` file has not been refreshed in 30 minutes | warning |
| `TextfileParseError` | node\_exporter could not parse a collector's output | warning |
Most are one-liners because `rules/smartctl.yml` has already reduced drive health to a
single `smartctl:health_code` per drive. Every alert carries the `host` label, so the
mail tells you which machine.
`ExporterDown` matters more than it looks: without it, every other alert here goes quiet
when a machine disappears — absence of data is not absence of a problem.
### Changing the rules
Thresholds are not options. Copy the file, edit it, and swap it in:
```nix
services.telemetry.server = {
alerts.defaultRules = false;
prometheus.ruleFiles = [
"${inputs.telemetry}/rules/smartctl.yml" # keep: the health verdict lives here
./my-alerts.yml
];
};
```
`promtool` runs over whatever you supply at build time, so a bad expression fails
`nixos-rebuild` rather than at 3am.
For routing — inhibit rules, a second receiver, severity-based routes — use
`alerts.settings`, which is merged over the generated Alertmanager config:
```nix
alerts.settings.route.routes = [{
matchers = [ "severity=\"warning\"" ];
receiver = "email";
repeat_interval = "7d"; # nag less about warnings
}];
```
### Notes
- Alertmanager's port is opened by `openFirewall` along with Prometheus'. Its UI is where
you silence alerts — and anyone who can reach it can silence anything, same trust model
as the Prometheus port.
- Alerts are grouped by `alertname` and `host`, so nine failing drives on one machine
arrive as one mail. `alerts.repeatInterval` (default 24h) decides how often a
still-firing alert nags you.
- Alertmanager runs `envsubst` over its config at start, so a literal `${...}` in any
string you add via `alerts.settings` will be substituted.
## Layout
```
flake.nix modules, collector packages, checks, dev shell
modules/
common.nix ports, shared by both halves
collectors.nix host-side exporters and timers
server.nix Prometheus, Loki, Grafana, proxy
dashboards.nix builds dashboard JSON into a provisionable form
packages.nix the two collectors as ordinary packages
collectors/*.py the collectors themselves; runnable by hand
dashboards/gen_*.py dashboard generators; the JSON is built, not committed
rules/smartctl.yml Prometheus recording rules
```
## Working on it
The ZFS Pools and Drive Health dashboards are **generated at build time** from
`dashboards/gen_*.py`; no JSON is committed, so there is nothing to keep in sync and no
way to ship a stale dashboard. To see what a generator currently produces:
```sh
python3 dashboards/gen_zfs.py | less
nix build .#nixosConfigurations.<host>.config.system.build.toplevel # or just deploy
```
(Flakes copy only git-tracked files into the store, so "gitignore the JSON and read it
from disk anyway" is not an option — it is generate, or commit.)
The collectors are plain scripts taking `--output PATH`, so you can see exactly what a
machine reports:
```sh
nix run .#zfs-metrics -- --output /tmp/zfs.prom
sudo nix run .#smart-selftest -- --output /tmp/smart.prom
```
`smart-selftest` needs privileges to open the devices. Without them smartctl still exits
0 with well-formed but empty JSON; the collector logs `no identity data` rather than
inventing placeholder values.
## Things that will bite you
- **`prometheus.scrapeInterval` deliberately sets two things.** Grafana derives
`$__rate_interval` from the datasource's `timeInterval`; if that disagrees with the real
scrape interval, `rate()` windows are mis-sized and rate-based panels intermittently
render "No data". Don't configure the Grafana datasource by hand.
- **The datasource UIDs `prometheus` and `loki` are fixed, not options.** The generated
dashboards reference them literally.
- **Grafana's secret key is mandatory** as of nixpkgs 26.05, because Grafana's upstream
default is a constant compiled into every release. By default this module generates one
on first start into `/var/lib/grafana/secret-key` (0600, owned by `grafana`), so there
is nothing to wire up. Two things to know: it must stay *stable*, or credentials
encrypted in Grafana's database stop decrypting — so keep the Grafana data directory on
persistent storage; and it sits beside the database it protects, so it defends against
a stolen `grafana.db` but not against filesystem access. Set
`grafana.secretKeyFile` to a path you manage instead once the database holds anything
worth protecting, such as a datasource password:
```nix
services.telemetry.server.grafana.secretKeyFile =
config.sops.secrets.grafana-secret-key.path; # turns generation off
```
- **Changing `collectorHosts` changes the label set.** Panels look empty for a minute
after first deploy while the recording rules re-evaluate; data from before the change
stays under the old labels.
- **smartd does not run self-tests just because it is enabled.** NixOS's default `-a`
flags contain no `-s` schedule, so self-test history stays empty:
```nix
services.smartd.defaults.autodetected = "-a -o on -s (S/../.././02|L/../../6/03)";
```
- **SAS drives have no power-on-hours attribute.** smartctl sources `power_on_time` for
SCSI from the Background Scan Results log page, which is not the drive's lifetime meter
and often reads far too low. Drive Health takes the larger of that and the lifetime
stamp in the drive's own self-test log — so it can exceed what `smartctl -x` prints.
- **Swap a drive and the identity in its row header may lag.** The header comes from
`smart_drive_label`, written by the self-test collector on a 15 minute timer, so a
replacement shows the removed drive's model and serial until the next run. It is only
*stuck* if the unit is failing — the collector rewrites the whole file atomically, so a
crash leaves the previous one in place indefinitely. Check with
`systemctl status smart-selftest-collector.service`, and `systemctl start` it to refresh
immediately.
- **The per-drive sections list the drives that are there *now*.** `$ata_device` and
`$sas_device` are instant queries rather than `label_values`, so removed drives stop
getting a row instead of lingering for the dashboard's whole time range — see the
comment on `DEVICE_QUERY` in `dashboards/gen_smart.py`. The trade is that they need a
sample within Prometheus' 5 minute lookback: when a machine has been unreachable longer
than that, its sections disappear rather than rendering empty. Fleet Overview and the
history graphs still work, because they read the range.
- **IPv6 literals need help.** The `host` label is built by stripping the port off
`__address__` with `([^:]+)(?::[0-9]+)?`, which a bracketed IPv6 address does not match.
Use `prometheus.extraScrapeConfigs` with an explicit label for those.
|