monero.loveA little love for the network.
← All guides

Mining / Monero / Observability

XMRig monitoring, part 1: collect and store metrics

Set up XMRig monitoring with a local API collector, Node Exporter, Alloy, Prometheus and Grafana. Part 1 includes configuration examples and a tested bundle.

XMRig monitoring, part 1: collect and store metrics

XMRig monitoring series

  1. Collect and store metrics
  2. Read the dashboards
  3. Logs, alerts and troubleshooting

Our XMRig monitoring setup collects miner statistics locally, stores time series in Prometheus, sends selected logs to Loki, and puts both in Grafana. The useful question is whether a miner is producing accepted work with fresh telemetry. A running process and a plausible hashrate do not answer that on their own.

We use the same dashboard to move from a symptom to a narrower question. Did the hashrate fall? Did the pool start rejecting work? Did another process consume CPU time? Did the miner stop reporting, or did its monitoring agent stop forwarding data?

Part 1 of this series builds the collection path we inspected in our running setup, using a smaller Linux example that readers can adapt. Our production collector covers additional services and hardware. The example below handles one XMRig process, with a consistent metric schema and explicit failure signals.

Download the example configuration bundle for the collector, service units, alert rules, generic Grafana dashboard and validation fixtures. The examples passed collector tests, alert scenarios and configuration syntax checks. They have not been deployed end to end on a fresh host. Adapt credentials, networking and notification delivery to your installation.

The dashboard images are real Grafana captures from November 4, 2026. They show one selected miner from 14:00 to 20:00 UTC, including a closer view from 15:45 to 16:15 UTC. Its visible name has been replaced with Miner A, and surrounding navigation has been cropped. The plotted values, axes, time range and units have not been rewritten. These views do not show our full inventory. The cream and orange artwork is illustration, not telemetry.

What the dashboard tells us

Real Grafana XMRig dashboard crop showing miner rate, API health and short and long hashrate windows

The upper portion of our XMRig dashboard, filtered to one miner. Proxy panels show “No telemetry” because this selection is a miner rather than a proxy. Missing proxy data here is not evidence of a proxy outage.

We arrange the XMRig view around four questions.

Question Signals we inspect What the signals cannot establish alone
Is the miner reporting useful work? API availability, hashrate windows, process uptime, accepted-share rate Actual pool payouts or profit
Is its pool connection working? Rejected work, connection state, reported latency, share difficulty The cause of every rejection or timeout
Is the host constraining it? CPU use, temperature sensors, memory, per-thread rate, huge-page coverage where available Wall power or a hardware diagnosis without supporting evidence
Can we trust the observation? Collector age, scrape success, agent health, ingestion and notification health Availability during failure of the monitoring system itself

The first row is deliberately small. Detailed panels remain available for investigation, but the first screen should show whether the selected miner is reporting, how its rate is moving, and which measurements are absent.

An API value of 1 means the collector completed the expected request and parsing. Recent accepted work needs a separate check. A smoothly rising uptime measures process continuity, while connection statistics describe its relationship with the pool.

The collection path

On the inspected miner, a systemd timer invokes a local collector roughly every 15 seconds. It reads XMRig's loopback HTTP API and writes numeric metrics to a textfile directory. Node Exporter exposes those metrics together with host statistics. Alloy scrapes the local exporter every 30 seconds and sends the samples to central Prometheus through authenticated TLS. A separate Alloy pipeline reads logs and forwards them to Loki. Grafana queries those stores.

Plain text
Miner host
  XMRig HTTP API on loopback
    -> local collector
    -> atomic .prom file
    -> Node Exporter on loopback
    -> Alloy scrape
    -> authenticated TLS -> Prometheus -> Grafana

  Selected systemd journal entries
    -> Alloy filtering and redaction
    -> authenticated TLS -> Loki -> Grafana

  Prometheus alert rules
    -> Alertmanager -> a tested notification receiver

The textfile between the collector and exporter matters. If a collection run fails halfway through writing, readers should still see either the complete previous file or the complete replacement. If the collector stops entirely, however, Node Exporter can continue serving the old file. That is why we monitor the collection timestamp as well as scrape success.

Our deployed metric names carry a site-specific prefix. The tutorial uses xmrig_ for miner values and mining_ for collector health. These names are an example exporter contract, not built-in Prometheus output from XMRig. Copying a query without matching the exporter schema is a common reason for an empty panel.

Paper collage linking a processor, log slips and a magnifying glass over a telemetry line

Prepare a small installation first

The worked example assumes a Linux host using systemd, an existing working XMRig installation, and a monitoring server reachable over a private network or authenticated gateway. It does not change the mining algorithm, wallet, pool destination, thread count or donation settings.

Install Python 3 on the miner. Install a verified Node Exporter release and put its binary at /usr/local/bin/node_exporter, or adapt the service to your distribution's package. Install Alloy using the official Linux installation instructions. The example assumes its service account is named alloy.

On the monitoring server, install Prometheus, Grafana, Loki, and Alertmanager. Use supported releases, verify their distribution artifacts, and record the versions or container digests you installed. The configurations here assume all four backends run on the same server. Container installations need service DNS names in place of host loopback addresses.

Build the path for one miner before adding more. A working local API request, a fresh textfile, and one queryable series make later failures much easier to locate.

Enable XMRig's local HTTP API

Merge the following objects into the existing XMRig JSON configuration. This is a fragment, not a replacement mining configuration.

JSON
{
  "api": {
    "id": "miner-a",
    "worker-id": "miner-a"
  },
  "http": {
    "enabled": true,
    "host": "127.0.0.1",
    "port": 18088,
    "access-token": "REPLACE_WITH_A_RANDOM_LOCAL_TOKEN",
    "restricted": true
  }
}

Choose an unused port and a random token. Port 18088 is a tutorial value. A fixed port lets the collector find the API after a restart. An explicit worker identifier avoids relying on a hostname-derived default. Keep restricted mode enabled for this read-only monitoring task. XMRig documents these settings in its API configuration reference.

Back up the existing configuration, validate its JSON, and arrange a controlled XMRig restart using the service manager that already owns the process. Keep the configuration file readable only by the accounts that need it. Do not copy it into a dashboard or paste its complete contents into a support ticket.

Create a separate collector account and directory. On a fresh host, the following commands establish the example paths. If either service account already exists, reuse it instead of running its creation command again.

Shell
sudo useradd --system --no-create-home --shell /usr/sbin/nologin xmrig-monitor
sudo useradd --system --no-create-home --shell /usr/sbin/nologin node-exporter
sudo install -d -o root -g xmrig-monitor -m 0750 /etc/xmrig-monitor
sudo install -d -o xmrig-monitor -g xmrig-monitor -m 0755 /var/lib/xmrig-monitor/textfile
sudo install -d -o root -g root -m 0755 /usr/local/lib/xmrig-monitor

Store the same API token in /etc/xmrig-monitor/api-token. Set its owner to root, group to xmrig-monitor, and permissions to 0640. Use a protected editor or your secret manager. The collector needs this token, not the rest of the mining configuration.

Test the API without putting the token in a command-line argument:

Shell
sudo -u xmrig-monitor python3 - <<'PY'
import json
from pathlib import Path
import urllib.request

token = Path('/etc/xmrig-monitor/api-token').read_text().strip()
request = urllib.request.Request(
    'http://127.0.0.1:18088/2/summary',
    headers={'Authorization': 'Bearer ' + token}
)
with urllib.request.urlopen(request, timeout=4) as response:
    data = json.load(response)
print({
    'uptime_seconds': data.get('uptime'),
    'hashrate_windows': data.get('hashrate', {}).get('total'),
    'accepted': data.get('results', {}).get('shares_good')
})
PY

This prints only selected fields. An authentication error points to the token or API configuration. Connection refusal points to the listener, port or process. A JSON response with a different structure requires an exporter adjustment. Do not solve an authentication problem by binding the API to every interface.

Loopback binding applies within a network namespace. If XMRig runs inside a container, a collector on the host cannot automatically reach that container's loopback listener. Place the collector in the same namespace or design an authenticated, narrowly scoped connection between them. Do not assume 127.0.0.1 means the same process environment on both sides.

Export a small, explicit set of metrics

XMRig's summary exposes hashrate windows, uptime and share counters. In the upstream implementation, shares_good counts accepted results and shares_total counts accepted plus rejected results. The example maps these to named Prometheus counters and excludes identifying fields and raw API responses. See XMRig's network-state implementation for the counter definitions.

Example metric Type Interpretation
xmrig_api_up Gauge The collector obtained and parsed a usable summary
xmrig_hashrate_hps{window="60s"} Gauge Miner-reported hashes per second for that window
xmrig_accepted_shares_total Counter Accepted results reported by this process
xmrig_submitted_shares_total Counter Accepted plus rejected results
xmrig_uptime_seconds Gauge XMRig process uptime
xmrig_pool_latency_milliseconds Gauge XMRig's reported result latency field
xmrig_share_difficulty Gauge Current share difficulty reported by XMRig
mining_collector_timestamp_seconds Gauge Time this collection attempt produced its output
mining_collector_duration_seconds Gauge Time spent on the attempt

Save this collector as /usr/local/lib/xmrig-monitor/xmrig-collector.py. It uses only the Python standard library.

Python
#!/usr/bin/env python3
"""Example single-miner collector. Numeric allowlist only. Python standard library."""
import json
import math
import os
from pathlib import Path
import tempfile
import time
import urllib.request


def finite(value):
    return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)


def collect(url, token):
    started = time.monotonic()
    lines = []

    def emit(name, kind, value, labels=""):
        if finite(value):
            if not any(line.startswith("# TYPE " + name + " ") for line in lines):
                lines.append(f"# TYPE {name} {kind}")
            lines.append(f"{name}{labels} {value}")

    ok = 0
    try:
        request = urllib.request.Request(url, headers={"Authorization": "Bearer " + token})
        with urllib.request.urlopen(request, timeout=4) as response:
            data = json.load(response)
        rates = data["hashrate"]["total"]
        results = data["results"]
        good, total = results["shares_good"], results["shares_total"]
        uptime = data["uptime"]
        if not isinstance(rates, list) or len(rates) != 3:
            raise ValueError("Unexpected hashrate windows")
        if not all(finite(v) and v >= 0 for v in (good, total, uptime)) or good > total:
            raise ValueError("Invalid counters")
        if any(v is not None and (not finite(v) or v < 0) for v in rates):
            raise ValueError("Invalid hashrate")
        for window, value in zip(("10s", "60s", "15m"), rates):
            emit("xmrig_hashrate_hps", "gauge", value, '{window="' + window + '"}')
        emit("xmrig_accepted_shares_total", "counter", good)
        emit("xmrig_submitted_shares_total", "counter", total)
        emit("xmrig_uptime_seconds", "gauge", uptime)
        emit("xmrig_pool_latency_milliseconds", "gauge", results.get("latency"))
        emit("xmrig_share_difficulty", "gauge", results.get("diff_current"))
        ok = 1
    except Exception:
        # Do not put response bodies, pool addresses or tokens into logs.
        lines = []
    emit("xmrig_api_up", "gauge", ok)
    emit("mining_collector_timestamp_seconds", "gauge", time.time())
    emit("mining_collector_duration_seconds", "gauge", time.monotonic() - started)
    return "\n".join(lines) + "\n"


def main():
    directory = Path(os.environ.get("METRICS_DIR", "/var/lib/xmrig-monitor/textfile"))
    token_path = Path(os.environ.get("TOKEN_FILE", "/etc/xmrig-monitor/api-token"))
    url = os.environ.get("XMRIG_SUMMARY_URL", "http://127.0.0.1:18088/2/summary")
    # Failure to read credentials or publish a file leaves freshness to detect it.
    token = token_path.read_text().strip()
    text = collect(url, token)
    descriptor, name = tempfile.mkstemp(prefix=".xmrig-", suffix=".tmp", dir=directory)
    try:
        with os.fdopen(descriptor, "w") as output:
            output.write(text)
            output.flush()
            os.fsync(output.fileno())
        os.chmod(name, 0o644)
        os.replace(name, directory / "xmrig.prom")
    finally:
        if os.path.exists(name):
            os.unlink(name)


if __name__ == "__main__":
    main()

The script has several intentional failure behaviours. A successful request with unavailable hashrate windows leaves those samples absent, preserving the distinction between startup and zero production. An API or parsing failure writes xmrig_api_up 0 and removes the old miner values from the replacement file. A token-file or filesystem failure prevents publication of a replacement, so the previous timestamp ages and the systemd unit records an error.

The script validates the fields it depends on before calling the response usable. Adapt that validation when changing XMRig versions or using a different API. It handles an XMRig miner, not XMRig Proxy. A proxy's hashrate windows and result fields need their own mapping.

The temporary file ends in .tmp, and the final rename happens within the destination directory. Node Exporter's textfile collector reads files ending in .prom without supporting sample timestamps. The collection timestamp above is therefore an ordinary metric value, not a third column appended to every sample.

Run the collector and Node Exporter

Save the collector service as /etc/systemd/system/xmrig-monitor.service:

INI / systemd
[Unit]
Description=Collect local XMRig metrics
After=network.target

[Service]
Type=oneshot
User=xmrig-monitor
Group=xmrig-monitor
ExecStart=/usr/bin/python3 /usr/local/lib/xmrig-monitor/xmrig-collector.py
TimeoutStartSec=10
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/xmrig-monitor/textfile
UMask=0022

Save its timer as /etc/systemd/system/xmrig-monitor.timer:

INI / systemd
[Unit]
Description=Collect XMRig metrics every 15 seconds

[Timer]
OnBootSec=15s
OnUnitActiveSec=15s
AccuracySec=1s

[Install]
WantedBy=timers.target

The service runs as the dedicated account and can write only to its metrics directory under the filesystem restrictions shown. The timer's interval is a scheduling target, not a guarantee of exact 15-second spacing. Watch the output timestamp rather than inferring freshness from the intended schedule.

Save this exporter unit as /etc/systemd/system/node-exporter.service:

INI / systemd
[Unit]
Description=Local host and mining metrics
After=network.target

[Service]
User=node-exporter
Group=node-exporter
ExecStart=/usr/local/bin/node_exporter --web.listen-address=127.0.0.1:9100 --collector.textfile.directory=/var/lib/xmrig-monitor/textfile
Restart=on-failure
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target

This is for a fresh installation. If a packaged Node Exporter is already running, configure its existing unit with the same listener and textfile directory instead of starting a second process on the port.

Load the units and make the first collection:

Shell
sudo systemctl daemon-reload
sudo systemctl start xmrig-monitor.service
sudo systemctl enable --now xmrig-monitor.timer node-exporter.service
sudo systemctl status xmrig-monitor.service xmrig-monitor.timer
curl --fail --silent http://127.0.0.1:9100/metrics > /tmp/mining-metrics.txt
grep -E '^(xmrig_|mining_collector_|node_textfile_scrape_error)' /tmp/mining-metrics.txt

A completed one-shot service can be inactive after a successful run. Check its exit status, the active timer and the file timestamp together. The metrics response should contain the miner fields, a recent collection time and node_textfile_scrape_error 0. A parse error here is an exporter problem, even if the API request worked.

The exporter listens only on loopback because Alloy scrapes it locally. If you choose central Prometheus scraping instead, use a private authenticated route to the exporter and adjust the topology deliberately. Do not configure both paths to ingest the same series under different job labels and then sum both copies.

Forward metrics with Alloy

This is the metrics portion of /etc/alloy/config.alloy:

HCL / Alloy
logging {
  level = "warn"
}

prometheus.scrape "miner" {
  targets = [{"__address__" = "127.0.0.1:9100", "instance" = "miner-a"}]
  job_name = "mining-hosts"
  scrape_interval = "30s"
  forward_to = [prometheus.remote_write.central.receiver]
}

prometheus.scrape "agent" {
  targets = [{"__address__" = "127.0.0.1:12345", "instance" = "miner-a"}]
  job_name = "alloy"
  scrape_interval = "30s"
  forward_to = [prometheus.remote_write.central.receiver]
}

prometheus.remote_write "central" {
  endpoint {
    url = "https://metrics.example.net/api/v1/write"
    tls_config {
      ca_file = "/etc/alloy/telemetry/ca.crt"
      cert_file = "/etc/alloy/telemetry/client.crt"
      key_file = "/etc/alloy/telemetry/client.key"
    }
  }
}

Replace miner-a with a stable identifier you control. Use the same identifier for host metrics, miner metrics and logs. If one host runs multiple miners, add a separate miner label to the collector output and adjust alert joins to include it. The single-miner example intentionally avoids that extra dimension.

metrics.example.net is a placeholder. Provision a server certificate whose identity matches your chosen endpoint, a trusted CA file, and a distinct client certificate and key for each agent. The Alloy account must be able to read its key, while unrelated users must not. Keep its HTTP management listener on loopback and give it a persistent storage directory.

Alloy's remote-write component writes incoming samples to a write-ahead log before forwarding them. That provides buffering during interruptions, subject to its configured retention and available disk space. A backlog can still become data loss. Monitor retries, failed sends, queue behaviour and the disk that holds the WAL.

At the gateway, accept authenticated agent traffic only on the intended ingestion routes. Our design separates ingestion from the Grafana login and other administrative interfaces. For this example, the gateway needs these two mappings:

Authenticated route Backend on the monitoring server
POST /api/v1/write Prometheus on 127.0.0.1:9090
POST /loki/api/v1/push Loki on 127.0.0.1:3100

Reject unrecognized routes and clients without valid certificates. Keep backend listeners private. A certificate authenticates a sending agent, but it does not by itself ensure the agent can only send an approved instance label. If that distinction matters to your threat model, enforce identity-to-label rules at a suitable ingestion layer rather than trusting labels supplied by the client.

Validate the Alloy file before restarting its service:

Shell
sudo alloy validate /etc/alloy/config.alloy
sudo systemctl restart alloy
sudo journalctl -u alloy -n 80 --no-pager

Use the actual binary and service names from your installation. A syntactically valid file still needs readable credentials, a trusted server certificate, a reachable endpoint and a receiver that accepts the write protocol.

Configure the central metrics store and Grafana

Prometheus needs its remote-write receiver enabled for this topology. Add the following arguments to the service or container command that starts it:

Plain text
--config.file=/etc/prometheus/prometheus.yml
--storage.tsdb.path=/var/lib/prometheus
--web.listen-address=127.0.0.1:9090
--web.enable-remote-write-receiver
--storage.tsdb.retention.time=30d
--storage.tsdb.retention.size=25GB

The retention values are example limits. Choose them against your sample volume and free space. A time limit and size limit are both constraints, so the size limit can shorten the available history. Leave headroom for the database and WAL instead of assigning the entire disk to retained blocks. The Prometheus storage guide explains the retention behaviour.

Use this configuration, with the alert rules from Part 3 saved under the referenced path:

YAML
global:
  scrape_interval: 30s
  evaluation_interval: 30s
rule_files:
  - /etc/prometheus/mining-rules.yml
scrape_configs:
  - job_name: prometheus
    static_configs:
      - targets: ['127.0.0.1:9090']
alerting:
  alertmanagers:
    - static_configs:
        - targets: ['127.0.0.1:9093']

The server scrapes itself. Miner and agent samples arrive through remote write, so they do not need a second central scrape job. The up series with job="mining-hosts" describes Alloy's local scrape of Node Exporter. It is not a direct central-server reachability probe.

Provision the Grafana data sources through a file in its provisioning/datasources directory:

YAML
apiVersion: 1
datasources:
  - name: Prometheus
    uid: prometheus
    type: prometheus
    access: proxy
    url: http://127.0.0.1:9090
    isDefault: true
    jsonData:
      timeInterval: 30s
  - name: Loki
    uid: loki
    type: loki
    access: proxy
    url: http://127.0.0.1:3100

These loopback URLs assume Grafana shares the host network namespace with the backends. In separate containers, use the corresponding private service addresses. Keep Grafana authenticated and disable anonymous access for operational dashboards. Verify each data source with Grafana's connection test and an actual query.

Start with xmrig_api_up, then xmrig_hashrate_hps{window="60s"}, then the timestamp-age query in Part 2. If those return data, add panels. A complex dashboard is a poor first test of an ingestion pipeline.

The accompanying configuration bundle includes xmrig-dashboard.json, a generic dashboard for the tutorial metric names. Import it through Grafana's dashboard import screen after provisioning the prometheus and loki data-source UIDs. Its selector uses the expected-miner records defined in Part 3. It is separate from the private dashboards in the screenshots and includes no production labels or endpoints. Grafana describes the upload flow in its dashboard import documentation.


Next: Part 2, read the dashboards

The collection path is in place. Next, follow one miner through the real Grafana screenshots: compare hashrate windows, separate accepted work from submitted results, and check whether the data is fresh enough to trust.

Support Monero Love

Help keep the public nodes and packages running. Send any amount of XMR.

Scan with a Monero wallet to donate to Monero Love.89hGTKxRVA3AUeKAbCRBt5TMSzq3MQZooNNsezCpNFjp2oeJPRx8AJeGKA8ziHZNqab78f4Vr1ntxhPDxX5298nnGZjHkhm