Skip to content

Metrics catalogue

Everything this repo adds on top of stock node_exporter and cAdvisor.

Package and reboot state

Written to fleet-updates.prom by fleet-update-metrics.sh, every 15 minutes and at boot.

Metric Type Meaning
apt_upgrades_pending{origin} gauge pending packages, by origin
apt_upgrades_security_pending gauge the security subset
node_reboot_required gauge 1 while a reboot is outstanding
fleet_reboot_required_since_timestamp_seconds gauge when it first became required
needrestart_kernel_status gauge 0 unknown, 1 current, 2 ABI pending, 3 version pending
needrestart_services_count gauge services running outdated binaries
fleet_unattended_upgrades_enabled gauge 1 only if apt will actually apply upgrades unattended
fleet_autoupdate_expected gauge 1 if the inventory puts this host in autoupdate
fleet_unattended_upgrades_last_run_timestamp_seconds gauge last unattended run
fleet_apt_last_update_timestamp_seconds gauge package cache freshness
fleet_dpkg_needs_configure gauge 1 if dpkg is wedged

fleet_reboot_required_since_timestamp_seconds is emitted only while pending

That is what makes the alert self-resolve on reboot, and what lets the dashboard table empty itself without a stale row.

needrestart_kernel_status is unreliable on Raspberry Pi OS

It compares the running kernel against the newest /boot/vmlinuz-*, but the Pi kernel comes from /boot/firmware. A Pi with both Debian and RPi kernels installed reports 3 indefinitely. No alert keys off it for this reason; it is dashboard information only.

fleet_unattended_upgrades_enabled needs all three conditions

The timer alone is not enough. apt-daily-upgrade.timer ships enabled on stock Debian, so testing only the timer reports "auto-patching is on" for a box that has never been configured to patch itself and may not even have the unattended-upgrade binary. That is the dangerous direction to be wrong in: an unpatched host looks covered.

It now requires the timer, an effective APT::Periodic::Unattended-Upgrade "1", and the binary on disk.

INTENT vs STATE: fleet_autoupdate_expected against fleet_unattended_upgrades_enabled

Templated from group membership, so it says whether a host is supposed to auto-patch, while the other says whether it does. Together they let fleet-autoupdates-disabled fire only on genuine drift, and stay silent on a deliberately hand-patched host like the hypervisor. Move a host between autoupdate and no_autoupdate and the alert's scope follows on its own.

Container update state

Written to fleet-docker.prom by fleet-docker-update.sh, after each run.

Metric Type Meaning
fleet_docker_update_last_run_timestamp_seconds gauge when it last completed
fleet_docker_update_duration_seconds gauge how long it took
fleet_docker_update_failed gauge 1 if anything errored
fleet_docker_compose_projects gauge projects found on the host
fleet_docker_projects_updated gauge projects whose images changed
fleet_docker_containers_recreated gauge containers moved to a new image
fleet_docker_image_bytes_reclaimed gauge freed by pruning
fleet_docker_containers_running gauge running after the update
fleet_docker_containers_unhealthy gauge failing a Docker healthcheck
fleet_docker_containers_restarting gauge stuck restarting

Per-process metrics

From prometheus.exporter.process, opt-in per host via alloy_enable_process; see Variables.

Metric Meaning
namedprocess_namegroup_num_procs processes in the group
namedprocess_namegroup_cpu_seconds_total CPU seconds, by mode
namedprocess_namegroup_memory_bytes{memtype="resident"} RSS; virtual exists but is misleading
namedprocess_namegroup_num_threads / ..._open_filedesc threads and open FDs
namedprocess_namegroup_oldest_start_time_seconds age of the oldest PID in the group

Grouped by groupname, which is the command name, so every PID of the same program is one series and a restart does not mint a new one.

comm and cmdline are not interchangeable in the matcher

comm is a list of exact command names; cmdline is a list of regexes. So comm = [".+"] is not "match everything", it matches a process literally named .+, which is nothing. The symptom is the worst kind: the component starts cleanly, the scrape pool comes up, and exactly zero series are produced. Use cmdline = [".+"].

SMART disk health

Written to fleet-smart.prom by fleet-smart-metrics, every 15 minutes and at boot, from ansible/roles/smart_metrics.

Metric Meaning
fleet_smart_device_health_ok 1 if the drive's own SMART self-assessment passes
fleet_smart_temperature_celsius current drive temperature
fleet_smart_power_on_hours / fleet_smart_power_cycle_count age and spin-ups
fleet_smart_reallocated_sectors remapped bad sectors (SATA)
fleet_smart_pending_sectors unreadable, not yet remapped: the urgent one
fleet_smart_offline_uncorrectable sectors that failed offline scan
fleet_smart_crc_errors cumulative SATA link faults; read the slope, not the value
fleet_smart_nvme_percentage_used_ratio rated write endurance consumed, 0-1
fleet_smart_nvme_available_spare_ratio spare blocks left, 0-1
fleet_smart_nvme_media_errors / ..._unsafe_shutdowns NVMe integrity counters
fleet_smart_devices_total SMART-capable devices found
fleet_smart_collection_timestamp_seconds when collection last ran

All are labelled host, device, model and serial, one series per physical drive.

Only bare metal can report these, and the reason differs per platform

An LXC guest sees the host's disks in /sys, so lsblk lists them, but has no /dev nodes and cannot open them. A KVM guest has a /dev/sda that answers device lacks SMART capability. An SD card does not implement SMART, and the eMMC health fields (life_time, pre_eol_info) are absent on real SD media.

So "no data" is the correct permanent state for every virtualised host, which is why every rule in rules-storage.yaml uses noDataState: OK and why fleet-smart-collector-stale exists to catch a genuinely dead collector instead.

Alert on the slope of fleet_smart_crc_errors, never the value

It is a cumulative lifetime counter that never resets, and it usually means a SATA cable rather than a dying platter. The HDD in this estate already carries 23 from a past event, so a > 0 rule would fire forever. fleet-smart-crc-errors-rising uses increase(...[24h]) > 0.

GPU telemetry

Scraped from the standalone nvidia_gpu_exporter service (see ansible/roles/gpu_exporter), installed only on hosts where nvidia-smi is present. Same figures nvtop shows interactively.

Metric Meaning
nvidia_smi_utilization_gpu_ratio GPU compute utilization, 0-1
nvidia_smi_memory_used_bytes / nvidia_smi_memory_total_bytes VRAM used / installed
nvidia_smi_temperature_gpu die temperature, Celsius
nvidia_smi_power_draw_watts current power draw
nvidia_smi_fan_speed_ratio fan duty cycle, 0-1

All are labelled host and uuid (one series per physical GPU), same as every other exporter in this stack.

Stock metrics worth knowing

Metric Used by
up{job=~"integrations/unix\|host-unix"} host-down, freshness. Not what you think
node_textfile_mtime_seconds detects a dead exporter script, free
node_systemd_unit_state{state="failed"} fleet-systemd-unit-failed
node_vmstat_oom_kill host-level OOM
node_timex_sync_status clock drift
prometheus_remote_storage_samples_failed_total remote-write health
container_last_seen{name!=""} container presence
container_start_time_seconds restarts and uptime
container_oom_events_total per-container OOM
container_health_state ambiguous

Query recipes

# per-host data age; every value should be < 30
time() - max by (host) (
  max_over_time(timestamp(up{job=~"integrations/unix|host-unix"})[6h:1m])
)

# hosts awaiting a reboot, with how long
(time() - fleet_reboot_required_since_timestamp_seconds)
  or (node_reboot_required == 1) - 1

# filesystem percent used, with the Proxmox exclusions
100 * (1 - (
  node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs|ramfs|autofs|nsfs|fuse\\.lxcfs",mountpoint!~"/(run|dev|sys|proc|var/lib/lxcfs|etc/pve)($|/).*"}
  /
  node_filesystem_size_bytes{fstype!~"tmpfs|overlay|squashfs|ramfs|autofs|nsfs|fuse\\.lxcfs",mountpoint!~"/(run|dev|sys|proc|var/lib/lxcfs|etc/pve)($|/).*"}
))

# load normalised by core count, so a Pi and a hypervisor share a threshold
max by (host) (node_load15)
  / on (host)
count by (host) (count by (host, cpu) (node_cpu_seconds_total{mode="idle"}))

# containers per host
count by (host) (count by (host, name) (container_last_seen{name!=""}))

# when did each host last patch itself, in hours
(time() - fleet_unattended_upgrades_last_run_timestamp_seconds) / 3600

Always exclude /etc/pve and lxcfs

/etc/pve is a FUSE mount that always reports 100% full, and lxcfs binds mirror guest filesystems. Without the exclusions the disk alerts fire forever on any PVE host.

LogQL recipes

# every package applied, fleet-wide audit trail
{unit="unattended-upgrades.service"} |= "Packages that will be upgraded"

# patching errors
{unit="unattended-upgrades.service"} |~ "(?i)(traceback|^E: |error|could not|failed to)"

# container logs for one service
{source="docker", container="matrix-synapse"}

# everything noisy on one host
{host="rpi5"} |~ "(?i)(error|failed|fatal|panic)"

# log volume split by source
sum by (host) (count_over_time({source="journal"}[5m]))
sum by (host) (count_over_time({source="docker"}[5m]))

Adding your own

Anything a shell script can compute becomes a metric. Write a .prom file into /var/lib/node_exporter/textfile_collector/.

TMP=$(mktemp /var/lib/node_exporter/textfile_collector/.mine.XXXXXX)
{
  echo "# HELP my_metric What it measures."
  echo "# TYPE my_metric gauge"
  echo "my_metric $value"
} > "$TMP"
chmod 0644 "$TMP"
mv -f "$TMP" /var/lib/node_exporter/textfile_collector/mine.prom

Write-then-rename, always

The collector will read a half-written file and export garbage. Create the temp file in the same directory so the rename is atomic within one filesystem.