Host Probes via the Textfile Collector Pattern
Getting a one-off measurement into Prometheus without writing an exporter.
The problem
node_exporter covers CPU, memory, disk and network out of the box. Sooner or later you
need something it does not cover: the age of the most recent backup file, whether a
certificate on disk is close to expiry, the exit status of a cron job that ran overnight.
None of these are worth a dedicated exporter — a whole HTTP server, a /metrics endpoint,
a systemd unit, a firewall rule, just to expose one gauge that changes once a day.
The obvious shortcut is to write the value somewhere and have something else read it. The naive version of that is a cron job appending to a log file and a second script tailing it, which is not a metrics pipeline, it is two scripts hoping to agree on a format.
node_exporter’s textfile collector is the actual answer: point it at a directory, and it
reads every *.prom file in that directory on every scrape, parses it as Prometheus
exposition format, and merges the result into /metrics as if it had collected the values
itself. Writing a metric becomes writing a text file.
That sounds trivial, and the failure modes are exactly why it is not:
# Broken. Do not copy this.
echo "backup_age_seconds $(stat -c %Y /var/backups/latest.tar.gz)" \
> /var/lib/node_exporter/textfile_collector/backup.prom
Run this and, most of the time, it works. Occasionally node_exporter scrapes the
directory at the exact moment this command is mid-write — truncated the old file, has not
finished writing the new content — and reads a zero-length or half-written .prom file.
Depending on the version, that either produces a parse error that node_exporter logs and
skips (so the metric silently disappears from that scrape) or, worse, a stale value that
never gets refreshed again because the script that was supposed to update it failed
partway and left the file in a state nothing will overwrite. Both failures are invisible
until someone asks why a graph has a gap or a flat line exactly where a real change should
be.
Working through it
Write atomically, or don’t call it a write
The fix is one of the oldest tricks in Unix: write to a temporary file in the same
filesystem, then rename() it over the target. A rename within the same filesystem is
atomic — a reader either sees the whole old file or the whole new one, never a partial
file, because the directory entry flips in one operation rather than the file’s contents
changing underneath a reader.
tmp=$(mktemp /var/lib/node_exporter/textfile_collector/.backup.prom.XXXXXX)
echo "backup_age_seconds $(stat -c %Y /var/backups/latest.tar.gz)" > "$tmp"
mv "$tmp" /var/lib/node_exporter/textfile_collector/backup.prom
mktemp in the same directory matters, not incidentally — mv across filesystems falls
back to copy-then-delete, which reintroduces the exact race this is meant to remove. Same
directory, same filesystem, atomic rename.
Decide what a stale file means, and enforce it
A script that stops running — a cron entry someone deleted, a systemd timer that started
failing silently — leaves its last .prom file in place forever. node_exporter will keep
serving that value on every scrape, correctly reporting a number that stopped being true
weeks ago. There is no complaint anywhere, because from node_exporter’s point of view
nothing is wrong: it read a valid file and exposed valid content.
Two independent guards close this: alert on the absence of a timestamp metric that the
script itself emits (backup_check_last_run_timestamp_seconds, evaluated in Prometheus as
time() - backup_check_last_run_timestamp_seconds > 3600), and set a mtime-based cleanup
that removes .prom files nothing has updated in longer than they should ever go
unrefreshed. The first tells you the check is stale; the second stops a genuinely dead
check from reporting a plausible-looking old value forever.
Get permissions right in one direction
node_exporter typically runs as its own unprivileged user and needs read access to the
textfile directory; whatever writes the .prom files needs write access to that same
directory. Running the writer as root because it is a cron job “somewhere” is how a
world-writable /var/lib/node_exporter/textfile_collector directory happens — and a
world-writable directory that feeds an unauthenticated /metrics endpoint is a path for
anything on the host to inject arbitrary metric names and values into your monitoring.
Create a dedicated group, put the collector directory in it, and add exactly the users or
services that need to write to it — nothing broader.
Emit the right metric type, correctly, every time
The exposition format wants a # HELP and # TYPE line per metric, and reusing a filename
for a metric whose type changes between runs (a gauge one day, a counter the next,
because two different scripts happened to write to the same file) produces output
node_exporter will refuse to parse. Pick one file per logical metric, declare its type
once, and never let two different producers write to the same .prom file.
The solution
A complete, runnable setup: a Python check script, a systemd service and timer that run it
on a schedule, and a docker-compose.yml that runs node_exporter against the same
directory so you can see the metric appear on a laptop.
#!/usr/bin/env python3
# /usr/local/bin/backup_age_check.py
import os
import tempfile
import time
TEXTFILE_DIR = "/var/lib/node_exporter/textfile_collector"
TARGET = os.path.join(TEXTFILE_DIR, "backup_age.prom")
BACKUP_FILE = "/var/backups/latest.tar.gz"
def write_atomically(path: str, content: str) -> None:
directory = os.path.dirname(path)
fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=".tmp-")
try:
with os.fdopen(fd, "w") as f:
f.write(content)
os.rename(tmp_path, path)
except Exception:
os.unlink(tmp_path)
raise
def main() -> None:
now = time.time()
if os.path.exists(BACKUP_FILE):
age_seconds = now - os.path.getmtime(BACKUP_FILE)
else:
age_seconds = -1 # sentinel: no backup file exists at all
content = (
"# HELP backup_age_seconds Age of the most recent backup file in seconds.\n"
"# TYPE backup_age_seconds gauge\n"
f"backup_age_seconds {age_seconds}\n"
"# HELP backup_check_last_run_timestamp_seconds "
"Unix time this check last completed.\n"
"# TYPE backup_check_last_run_timestamp_seconds gauge\n"
f"backup_check_last_run_timestamp_seconds {now}\n"
)
write_atomically(TARGET, content)
if __name__ == "__main__":
main()
# /etc/systemd/system/backup-age-check.service
[Unit]
Description=Write backup age metric for node_exporter textfile collector
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup_age_check.py
User=metrics-writer
Group=node-exporter-textfile
# /etc/systemd/system/backup-age-check.timer
[Unit]
Description=Run backup-age-check every 5 minutes
[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
AccuracySec=30s
[Install]
WantedBy=timers.target
Enable it on a real host with:
sudo useradd --system --no-create-home metrics-writer
sudo groupadd node-exporter-textfile
sudo usermod -a -G node-exporter-textfile metrics-writer
sudo mkdir -p /var/lib/node_exporter/textfile_collector
sudo chgrp node-exporter-textfile /var/lib/node_exporter/textfile_collector
sudo chmod 2775 /var/lib/node_exporter/textfile_collector
sudo systemctl daemon-reload
sudo systemctl enable --now backup-age-check.timer
chmod 2775 sets the setgid bit, so files created in the directory inherit the group,
which is what lets node_exporter’s own user read them without needing to be in every
writer’s primary group.
To see the whole thing work end to end on a laptop, without touching a real host, this
docker-compose.yml runs the check once and starts node_exporter pointed at the same
directory:
# docker-compose.yml
services:
backup-check:
image: python:3.12-slim
volumes:
- textfile-data:/var/lib/node_exporter/textfile_collector
- ./backup_age_check.py:/usr/local/bin/backup_age_check.py:ro
command: >
sh -c "mkdir -p /var/backups &&
touch /var/backups/latest.tar.gz &&
python3 /usr/local/bin/backup_age_check.py"
node-exporter:
image: prom/node-exporter:v1.8.2
depends_on:
- backup-check
volumes:
- textfile-data:/var/lib/node_exporter/textfile_collector
command:
- "--collector.textfile.directory=/var/lib/node_exporter/textfile_collector"
ports:
- "9100:9100"
volumes:
textfile-data:
Run it and check the metric appears:
docker compose up --abort-on-container-exit backup-check
docker compose up -d node-exporter
curl -s http://localhost:9100/metrics | grep backup_age_seconds
Expected output:
# HELP backup_age_seconds Age of the most recent backup file in seconds.
# TYPE backup_age_seconds gauge
backup_age_seconds 0.842193
Conclusion
The textfile collector is a small piece of glue and it is tempting to treat it as too simple to get wrong. The two ways it does go wrong — a torn write and a script that died without anyone noticing — are both invisible at the point they happen and only visible much later as a graph that looks wrong for reasons nobody can immediately explain.
Two things generalise past this specific pattern:
Atomic replace-by-rename is the correct way to publish a file another process reads
concurrently, whether that file is a Prometheus textfile, a config another daemon
watches, or a state file a script checks on startup. Anywhere a writer and a reader do not
coordinate directly, write-temp-then-rename is the coordination.
A value that stopped updating is a different failure from a value that is wrong, and it
needs its own alert. node_exporter has no way to know your backup check died; it only
knows the file it read parsed correctly. Emitting a “last successfully ran at” timestamp
alongside the actual measurement is what turns silent staleness into something Prometheus
can catch.