promcsv
What it does
promcsv turns Prometheus metrics into CSV files for systems that do not speak
Prometheus. It scrapes a configured set of /metrics endpoints on a fixed
interval and writes one CSV file per target per cycle - target outputs are
never combined. Files land atomically in a ready/ directory, are optionally
gzip-compressed and uploaded to S3 or SFTP, and are cleaned up by a retention
sweep so disk usage stays bounded.
It is built to run unattended for months as a systemd service, but it runs just as well in the foreground or from cron.
Supported features
- One CSV per target per cycle with a fixed 5-column schema
(
timestamp,metric,type,labels,value) that never changes shape - Wall-clock-aligned scraping (a 5m interval fires at :00, :05, :10, ...), targets scraped concurrently, one failing target never affects the others
- Optional gzip compression (
.csv.gz), applied once at write time - Upload to S3 (including S3-compatible stores like MinIO; SSE/KMS and
storage class supported) or SFTP (key or password auth, strict host-key
verification), or no upload - consumers pick files up from
ready/ - Flat or per-target remote layout, one config switch
- Atomic file handling everywhere - no partial files, local or remote
- Automatic retention cleanup; upload outages self-recover by draining the backlog once the endpoint returns
status.jsonhealth file for external monitoring and alerting- systemd-native:
Type=notifywatchdog, hardened unit, journald logging - Strict config validation with did-you-mean hints (
--validate-config) - Small footprint: one RPM or wheel; only
requestsandPyYAMLrequired (boto3/paramiko only for the upload target you actually use)
Build
Build the RPM (Rocky/RHEL 8+)
One-time build-host prerequisites:
dnf install rpm-build python3.12-devel python3.12-pip \
python3.12-setuptools python3.12-wheel systemd
python3.12 -m pip install build
Then:
./packaging/build-rpm.sh
The artifacts are written to dist/:
python3.12-promcsv-<version>-1.el8.noarch.rpm (install this) and the
matching .src.rpm (for rebuilding on other EL releases).
Build the wheel
No prerequisites beyond Python 3.12 - no venv, no test dependencies:
python3.12 -m pip install build
python3.12 -m build --wheel
The artifact is written to dist/:
promcsv-<version>-py3-none-any.whl - that single file is what you copy to
the target host.
Install
Two supported paths: the RPM (recommended on Rocky/RHEL 8+) or the wheel (any Linux). Do not mix them on one host.
Install from RPM (Rocky/RHEL 8+, recommended)
dnf install ./python3.12-promcsv-<version>-1.el8.noarch.rpm
vi /etc/promcsv/config.yaml
promcsv -c /etc/promcsv/config.yaml --validate-config
systemctl enable --now promcsv
For S3 or SFTP upload, also install the extra package (it has no python3.12
RPM): python3.12 -m pip install boto3 (S3) or paramiko (SFTP).
Install from wheel (any Linux)
Prerequisite: Python 3.12 or newer on the host. On Rocky/RHEL 8:
dnf install python3.12 python3.12-pip
Copy the wheel over and install it as root (system-wide):
sudo python3.12 -m pip install promcsv-*.whl
# When upload.target is s3, also install boto3:
sudo python3.12 -m pip install boto3
# When upload.target is sftp, also install paramiko:
sudo python3.12 -m pip install paramiko
On hosts with a hardened root umask (0027/0077), pip creates the package
directories without world-read, and other users then cannot import the
package (ModuleNotFoundError: No module named 'promcsv'). Install with a
safe umask:
sudo sh -c 'umask 022 && python3.12 -m pip install promcsv-*.whl'
Create the configuration from the built-in example and validate it (no git checkout needed - the files ship inside the package):
install -d -m 0755 /etc/promcsv
promcsv --print-config > /etc/promcsv/config.yaml # then edit
promcsv -c /etc/promcsv/config.yaml --validate-config
At this point the tool is fully usable from the command line
(promcsv -c /etc/promcsv/config.yaml, or --once from cron). To run it as
a systemd service, complete the one-time setup below - the RPM path does all
of this automatically.
Run as a systemd service (wheel installs only)
One-time host setup:
# service user and directories
useradd -r -s /sbin/nologin promcsv
install -d -o promcsv -g promcsv /var/data/promcsv
chgrp promcsv /etc/promcsv /etc/promcsv/config.yaml
chmod 0750 /etc/promcsv; chmod 0640 /etc/promcsv/config.yaml
# verify the service user can load the package (catches permission problems)
sudo -u promcsv /usr/bin/python3.12 -c "import promcsv" # must print nothing
# unit, log rotation and credentials template
promcsv --print-unit > /usr/lib/systemd/system/promcsv.service
promcsv --print-logrotate > /etc/logrotate.d/promcsv
promcsv --print-env > /etc/promcsv/env && chmod 0600 /etc/promcsv/env
systemctl daemon-reload
systemctl enable --now promcsv
The printed unit's ExecStart= assumes /usr/local/bin/promcsv; if
command -v promcsv shows a different path (e.g. a venv), edit ExecStart=
in the installed unit accordingly.
CLI
promcsv -c /etc/promcsv/config.yaml [--validate-config | --once]
-c, --config FILE- path to the YAML config (required).--validate-config- load and validate the config, print a report, and exit 0 (valid) or 2 (invalid). Read-only; touches nothing.--once- run a single scrape cycle and exit: 0 if at least one target was scraped successfully, 1 otherwise. Cron fallback / smoke test.--print-config- print the example configuration and exit.--print-unit- print the systemd unit and exit. The printedExecStart=assumes/usr/local/bin/promcsv; ifcommand -v promcsvshows a different path (e.g. a venv), editExecStart=accordingly.--print-logrotate- print the logrotate snippet and exit.--print-env- print the/etc/promcsv/envtemplate and exit.--version- print version and exit.
The --print-* flags need no configuration file and are mutually exclusive
with each other and with --once/--validate-config.
Exit codes:
| Code | Meaning |
|---|---|
| 0 | clean shutdown / successful --once cycle |
| 1 | runtime fatal (lock held, stuck scrape threads, unhandled error) |
| 2 | configuration error |
The unit sets RestartPreventExitStatus=2: a broken config exits 2 and is
not restarted, so systemd does not loop on an error no retry can fix.
Runtime failures exit 1 and are restarted after RestartSec.
CSV format (for consumers)
Every file starts with the header:
timestamp,metric,type,labels,value
Example:
timestamp,metric,type,labels,value
2026-07-21T10:15:00Z,connections_active,gauge,,412
2026-07-21T10:15:00Z,messages_total,counter,direction=tx;interface=eth0,908311
2026-07-21T10:15:00Z,request_duration_seconds_bucket,histogram,le=0.5,17734
2026-07-21T10:15:00Z,request_duration_seconds_sum,histogram,,5401.25
2026-07-21T10:15:00Z,setup_seconds,summary,quantile=0.99,0.087
Rules:
- timestamp - the cycle's scrape time in UTC, identical for every row in a
file. Format per
csv.timestamp_format:iso8601(2026-07-21T10:15:00Z) orepoch(integer Unix seconds). - metric - the sample name exactly as exposed (e.g.
foo_bucket,foo_sum). - type - from the endpoint's
# TYPEmetadata:counter,gauge,histogram,summaryoruntyped. Child samples (_bucket,_sum,_count,_created) inherit their family's type. Samples without TYPE metadata areuntyped. - labels -
key=value;key=value, sorted by key; empty when the sample has no labels. Within label values, backslash,;and=are backslash-escaped (\\,\;,\=). - CSV quoting - standard RFC 4180: fields containing the delimiter,
"or newlines are quoted, embedded quotes doubled. Consume the files with a real CSV parser, or configure a delimiter that cannot appear in your data. - value - floats with an integral value are rendered as bare integers
(up to 2^53 in magnitude);
+Inf,-InfandNaNare passed through literally.
Filenames are <label>_<YYYYMMDDTHHMMSSZ>.csv (UTC cycle time) - .csv.gz
with compression on. On the rare name collision a _1, _2, ... suffix is
inserted before the full extension (app_..._1.csv, app_..._1.csv.gz).
Compression
csv.compress: gzip writes .csv.gz files instead of .csv. The gzip stream
is deterministic (mtime=0 in the header), so identical content produces
identical bytes. Consumers use any gzip-capable reader (zcat, Python
gzip, ...). Flipping the flag between restarts is safe - ready/ may
transiently hold both extensions, and upload and retention handle both. The
remote object keeps the exact local name and bytes; nothing is re-compressed
or renamed on upload.
Directory lifecycle
output_dir (default layout under /var/data/promcsv):
output_dir/
├── tmp/ in-progress files (*.part); purged at startup
├── ready/ completed CSVs awaiting upload or pickup
├── uploaded/ CSVs successfully uploaded
├── status.json machine-readable health, rewritten atomically each cycle
└── .lock single-instance flock (never deleted)
Flow: each CSV is written to tmp/, fsynced, then atomically renamed into
ready/ - readers never see a partial file. When an upload succeeds, the file
moves from ready/ to uploaded/. The retention sweep deletes files older
than retention from both ready/ and uploaded/; deletion from ready/
is the disk-safety bound (it is logged as a WARNING when an uploader is
configured, since it means undelivered data was lost). With
upload.target: none, ready/ is the hand-off point: consumers collect files
from there themselves before retention removes them.
S3 upload support
Requires boto3 (sudo python3.12 -m pip install boto3). Config:
upload:
target: s3
s3:
bucket: my-metrics-bucket
prefix: metrics/ # optional; object keys become <prefix><filename>
region: eu-west-1
# endpoint_url: http://minio:9000 # optional: non-AWS S3-compatible storage (region then optional)
# sse: aws:kms # optional server-side encryption: AES256 | aws:kms
# kms_key_id: <arn-or-id> # optional; requires sse: aws:kms
# storage_class: STANDARD_IA # optional; passed through verbatim
Credentials never go in the YAML - the S3 client uses the standard boto3
credential chain: environment variables, ~/.aws/credentials, or an instance
profile / IAM role. For environment variables, uncomment the placeholders in
/etc/promcsv/env (installed by the RPM; on wheel installs create it with
promcsv --print-env; keep it mode 0600, root-owned):
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
The unit already wires it in via EnvironmentFile=-/etc/promcsv/env (the -
makes the file optional). For non-AWS S3-compatible storage (MinIO, Ceph RGW,
...), set upload.s3.endpoint_url; region then becomes optional.
The uploaded object keeps the exact local name and bytes (ContentType is
text/csv, or application/gzip for compressed files). Pruning the remote
side is the consumer's responsibility - at 6 targets on a 5m interval the
prefix grows by ~1,700 files/day.
Retry semantics: connection failures, HTTP 5xx and throttling abort the upload
pass and retry next cycle; per-file permanent errors (AccessDenied, invalid
KMS key) are skipped and surface as permanent_failures_pending in
status.json.
SFTP upload support
Requires paramiko (sudo python3.12 -m pip install paramiko). Config:
upload:
target: sftp
sftp:
host: sftp.example.com
port: 22 # optional, default 22
username: metrics
key_file: /etc/promcsv/sftp_key # private key auth (recommended)
remote_dir: /upload/metrics # files land in <remote_dir>
known_hosts: /etc/promcsv/known_hosts # strict host-key verification (required)
Key setup (then install sftp_key.pub on the server):
ssh-keygen -t ed25519 -f /etc/promcsv/sftp_key -N '' -C promcsv
chown root:promcsv /etc/promcsv/sftp_key && chmod 0640 /etc/promcsv/sftp_key
Host key verification is strict; capture the server's key into known_hosts:
ssh-keyscan -p 22 sftp.example.com > /etc/promcsv/known_hosts
Warning - trust on first use. ssh-keyscan records whatever key answered
at that moment. Verify the fingerprint out-of-band with the server owner
(ssh-keygen -lf /etc/promcsv/known_hosts) before trusting it - strict
verification is only as strong as this first capture.
Password auth alternative: omit key_file and uncomment
PROMCSV_SFTP_PASSWORD=... in /etc/promcsv/env (installed by the RPM; on
wheel installs create it with promcsv --print-env; mode 0600, root-owned;
systemd's EnvironmentFile delivers it) - never in the YAML.
The uploaded file keeps the exact local name and bytes. Each upload is staged
as <filename>.part and atomically renamed on completion, so the server never
exposes a partial file. remote_dir: / is supported for chrooted SFTP
accounts. Pruning the remote side is the consumer's responsibility (see the S3
section for the growth math).
Retry semantics: connection failures (host unreachable, authentication,
host-key mismatch) abort the upload pass and retry next cycle; per-file
permanent errors (remote permissions, quota) are skipped and surface as
permanent_failures_pending in status.json.
Monitoring and alerting
<output_dir>/status.json is rewritten atomically after every cycle:
| Field | Meaning |
|---|---|
version |
promcsv version |
state |
running or stopped; written running at startup and every cycle, stopped by a clean shutdown. A crash/SIGKILL leaves running behind - that is the signal that the process was NOT stopped properly |
started_at |
UTC start time of the current (or last) process |
stopped_at |
UTC time of the clean shutdown, null while running |
stop_reason |
null while running; else e.g. signal SIGTERM, once complete, fatal: ... |
last_cycle |
UTC timestamp of the last completed cycle (null before the first cycle of this process) |
last_cycle_duration_seconds |
wall time of that cycle |
targets.<label>.last_success |
UTC time of the target's last successful scrape (null if never) |
targets.<label>.consecutive_failures |
failed cycles in a row for this target (0 = healthy) |
upload |
null when upload.target is none, else: |
upload.last_success |
UTC time of the last successful upload |
upload.consecutive_retryable_failures |
upload passes in a row aborted by a retryable error (endpoint down, throttling) |
upload.permanent_failures_pending |
files in ready/ whose upload fails permanently (e.g. AccessDenied) |
upload.last_failure |
{kind, detail, at} of the most recent failed upload, or null; set on every failed upload, cleared on the next success |
ready_backlog |
CSV files currently waiting in ready/ |
uploaded_this_cycle |
files uploaded during the last cycle |
Staleness rule: define staleness as now - max(last_cycle, started_at)
exceeding your threshold (treat a null last_cycle as absent) - a freshly
restarted daemon has last_cycle: null for the whole first alignment wait,
and a naive last_cycle-only check would page on every restart.
Lifecycle decision table: stale + state: running -> the daemon died or
hung -> page. state: stopped -> deliberate shutdown (or between --once
cron runs) -> suppress the staleness page; apply your "service down" policy
instead. The file is never deleted - the last-known state is kept for
forensics.
Suggested alerts: staleness per the rule above (daemon stalled or down),
ready_backlog growing over time (uploads or consumers falling behind), any
consecutive_failures or permanent_failures_pending above 0 for sustained
periods.
Upload alerting rule: if upload.consecutive_retryable_failures keeps
climbing and upload.last_failure.kind is one of
AuthenticationException, BadHostKeyException (SFTP) or AccessDenied,
InvalidAccessKeyId, SignatureDoesNotMatch (S3), page a human - these are
credential, host-key or bucket-policy problems and never self-recover. Other
retryable kinds are self-recovering outages (endpoint down, throttling); the
backlog drains on its own once the endpoint returns.
Logging: journald by default (journalctl -u promcsv). Errors are logged on
state change - first failure of a target/upload, then a reminder every 10th
consecutive failing cycle, then a recovery line - plus exactly one INFO
summary line per cycle
(cycle: targets 6/6, rows 5321, written 6, uploaded 6, backlog 0, 1.2s).
Watchdog: the unit sets WatchdogSec=900. Watchdog pings are time-driven
(sent on a fixed cadence during scraping, uploading and idle waits), so the
900-second deadline works with any scrape_interval - no tuning needed.
Operational notes and best practices
- Validate before every restart. The config is read once at startup;
changes require
systemctl restart promcsv. Runpromcsv -c ... --validate-configfirst - a broken config exits 2 and the service stays down until fixed (deliberately, to avoid a restart loop). - Smoke-test with
--once. After installing or changing the config, one--oncerun against a scratchoutput_dirshows exactly what will be produced, without touching the service. - Single instance per output_dir. An exclusive lock on
<output_dir>/.lockguards the directory. Runningpromcsv --oncebeside the daemon exits 1 with a clear message - by design; point it at a differentoutput_dirif you need a parallel run. - Target isolation. Targets are scraped concurrently; one target being down (or slow, or hung) never affects the others' CSVs. A scrape thread stuck beyond the timeout is abandoned; if stuck threads persist, the daemon exits 1 for a clean systemd restart.
- Upload outages self-recover. Files stay in
ready/and the backlog drains automatically once the endpoint returns - time-boxed to half the scrape interval per cycle, in filename order (per-target oldest first), so scraping cadence is never starved. - Permanent upload errors (S3: AccessDenied, invalid KMS key; SFTP: remote
permissions, quota) are skipped and retried each cycle until retention
removes the file; they are visible as
upload.permanent_failures_pendinginstatus.jsonand in the logs. - Upgrade the RPM, never reinstall it.
dnf upgradepreserves your edited/etc/promcsv/config.yamlandenv(they are%config(noreplace)), butdnf reinstallof the same version resets them to the packaged defaults and moves your files to.rpmsave- standard RPM behavior, easy to trip over.
Development
Running the tests requires a one-time setup (a virtualenv with the package and the test tools):
python3.12 -m venv .venv
. .venv/bin/activate
pip install -e ".[dev]"
Then:
pytest -q -m "not slow" # default run (fast, skips the soak test)
pytest -q # everything, including the slow soak test
pytest -q -m "not slow" --cov=promcsv --cov-branch --cov-report=term-missing # with coverage
To build the RPM or the wheel, see the Build section - neither needs the virtualenv or the test dependencies.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file promcsv-1.4.0.tar.gz.
File metadata
- Download URL: promcsv-1.4.0.tar.gz
- Upload date:
- Size: 89.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
29e3ce1651470c9f6fe5036110a2631f464f0ae4ccf54757f6de5f7ae0ec46b5
|
|
| MD5 |
cb23975c00159405731502866151d0f7
|
|
| BLAKE2b-256 |
742e6af3dbc24aa9d73f073ca28e80a636363efad4571c5b9334f7ed22396a5f
|
File details
Details for the file promcsv-1.4.0-py3-none-any.whl.
File metadata
- Download URL: promcsv-1.4.0-py3-none-any.whl
- Upload date:
- Size: 44.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4049892102ca50290cfaea64496d3a9572a85b3dd66dc67f3b765d1c3480cd58
|
|
| MD5 |
9474708b190575ee650aba7104ad5ce9
|
|
| BLAKE2b-256 |
a5b799711db2e967d935bc45125905719447e58c8589549f508ba3c25f14e16b
|