diff --git a/tools/prometheus-exporter/README.md b/tools/prometheus-exporter/README.md new file mode 100644 index 000000000..81bd41050 --- /dev/null +++ b/tools/prometheus-exporter/README.md @@ -0,0 +1,68 @@ +# RustChain Prometheus Exporter + +Prometheus exporter for monitoring RustChain blockchain nodes. + +## Features + +- Scrapes RustChain node RPC endpoints periodically +- Exposes node health, epoch, miner counts, and per-miner antiquity multipliers +- Configurable scrape interval and target node URL +- Built-in `/health` endpoint for liveness probes + +## Metrics + +| Metric | Type | Description | +|--------|------|-------------| +| `rustchain_node_up` | gauge | 1 if node is reachable, 0 otherwise | +| `rustchain_node_version_info` | info | Node version string | +| `rustchain_epoch` | gauge | Current epoch number | +| `rustchain_miners_total` | gauge | Total registered miners | +| `rustchain_miners_active` | gauge | Currently active miners | +| `rustchain_miner_antiquity_multiplier` | gauge | Per-miner antiquity reward multiplier (label: `miner`) | +| `rustchain_last_scrape_timestamp` | gauge | Unix timestamp of last successful scrape | + +## Installation + +```bash +pip install -r requirements.txt +``` + +## Usage + +```bash +# Start with defaults (scrapes localhost:8080, exports on port 9200) +python rustchain_exporter.py + +# Custom configuration +RUSTCHAIN_NODE_URL=http://10.0.0.5:8080 \ +PROMETHEUS_EXPORTER_PORT=9200 \ +SCRAPE_INTERVAL=30 \ +python rustchain_exporter.py +``` + +## Configuration + +| Environment Variable | Default | Description | +|---------------------|---------|-------------| +| `RUSTCHAIN_NODE_URL` | `http://localhost:8080` | RustChain node base URL | +| `PROMETHEUS_EXPORTER_HOST` | `0.0.0.0` | Exporter listen address | +| `PROMETHEUS_EXPORTER_PORT` | `9200` | Exporter listen port | +| `SCRAPE_INTERVAL` | `15` | Scrape interval in seconds | + +## Prometheus Configuration + +```yaml +scrape_configs: + - job_name: rustchain + static_configs: + - targets: + - localhost:9200 + scrape_interval: 15s +``` + +## Docker + +```bash +docker build -t rustchain-exporter . +docker run -e RUSTCHAIN_NODE_URL=http://node:8080 -p 9200:9200 rustchain-exporter +``` diff --git a/tools/prometheus-exporter/requirements.txt b/tools/prometheus-exporter/requirements.txt new file mode 100644 index 000000000..6f8d78b0f --- /dev/null +++ b/tools/prometheus-exporter/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.28.0 +flask>=2.3.0 diff --git a/tools/prometheus-exporter/rustchain_exporter.py b/tools/prometheus-exporter/rustchain_exporter.py new file mode 100644 index 000000000..8bb11b80b --- /dev/null +++ b/tools/prometheus-exporter/rustchain_exporter.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +""" +Prometheus Exporter for RustChain Nodes +======================================== + +Scrapes RustChain node RPC endpoints and exposes metrics +in Prometheus format for monitoring and alerting. + +Usage: + RUSTCHAIN_NODE_URL=http://localhost:8080 python rustchain_exporter.py +""" + +import os +import time +import logging +from threading import Thread, Lock +from typing import Optional, Dict, Any + +import requests +from flask import Flask, Response + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +# Configuration +NODE_URL = os.getenv("RUSTCHAIN_NODE_URL", "http://localhost:8080") +EXPORTER_HOST = os.getenv("PROMETHEUS_EXPORTER_HOST", "0.0.0.0") +EXPORTER_PORT = int(os.getenv("PROMETHEUS_EXPORTER_PORT", "9200")) +SCRAPE_INTERVAL = int(os.getenv("SCRAPE_INTERVAL", "15")) + +# Metrics storage +_metrics_lock = Lock() +_metrics: Dict[str, Any] = { + "node_up": 0, + "node_version": "", + "epoch": 0, + "miners_total": 0, + "miners_active": 0, + "last_scrape": 0, +} + +# Per-miner antiquity multipliers: {miner_id: multiplier} +_miner_multipliers: Dict[str, float] = {} +_miner_multipliers_lock = Lock() + + +def _fetch_json(url: str, timeout: int = 10) -> Optional[Dict[str, Any]]: + """Fetch JSON from a URL with error handling.""" + try: + resp = requests.get(url, timeout=timeout) + resp.raise_for_status() + return resp.json() + except Exception as e: + logger.warning("Failed to fetch %s: %s", url, e) + return None + + +def _scrape_once() -> None: + """Perform a single scrape of the RustChain node.""" + # Health / status + data = _fetch_json(f"{NODE_URL}/api/status") + with _metrics_lock: + if data: + _metrics["node_up"] = 1 + _metrics["node_version"] = data.get("version", "unknown") + _metrics["epoch"] = data.get("epoch", data.get("current_epoch", 0)) + _metrics["miners_total"] = data.get("total_miners", data.get("miners", 0)) + _metrics["miners_active"] = data.get("active_miners", 0) + else: + _metrics["node_up"] = 0 + + # Per-miner antiquity multipliers + miners_data = _fetch_json(f"{NODE_URL}/api/miners") + miner_map: Dict[str, float] = {} + if isinstance(miners_data, list): + for m in miners_data: + mid = m.get("miner") or m.get("miner_id", "") + mult = m.get("antiquity_multiplier", m.get("multiplier", 1.0)) + if mid: + miner_map[mid] = float(mult) + elif isinstance(miners_data, dict): + for m in miners_data.get("miners", miners_data.get("data", [])): + if isinstance(m, dict): + mid = m.get("miner") or m.get("miner_id", "") + mult = m.get("antiquity_multiplier", m.get("multiplier", 1.0)) + if mid: + miner_map[mid] = float(mult) + + with _miner_multipliers_lock: + _miner_multipliers.clear() + _miner_multipliers.update(miner_map) + + with _metrics_lock: + _metrics["last_scrape"] = int(time.time()) + + +def _scraper_loop() -> None: + """Background thread that periodically scrapes the node.""" + while True: + try: + _scrape_once() + logger.info( + "Scraped: up=%s epoch=%s miners=%s", + _metrics["node_up"], + _metrics["epoch"], + _metrics["miners_total"], + ) + except Exception as e: + logger.error("Scrape error: %s", e) + time.sleep(SCRAPE_INTERVAL) + + +@app.route("/metrics") +def prometheus_metrics(): + """Return metrics in Prometheus exposition format.""" + lines = [] + + with _metrics_lock: + node_up = _metrics["node_up"] + version = _metrics["node_version"] + epoch = _metrics["epoch"] + miners_total = _metrics["miners_total"] + miners_active = _metrics["miners_active"] + last_scrape = _metrics["last_scrape"] + + lines.append("# HELP rustchain_node_up Whether the RustChain node is reachable (1=up, 0=down)") + lines.append("# TYPE rustchain_node_up gauge") + lines.append(f"rustchain_node_up {node_up}") + + lines.append("# HELP rustchain_node_version_info Node version information") + lines.append("# TYPE rustchain_node_version_info gauge") + lines.append(f'rustchain_node_version_info{{version="{version}"}} 1') + + lines.append("# HELP rustchain_epoch Current epoch number") + lines.append("# TYPE rustchain_epoch gauge") + lines.append(f"rustchain_epoch {epoch}") + + lines.append("# HELP rustchain_miners_total Total registered miners") + lines.append("# TYPE rustchain_miners_total gauge") + lines.append(f"rustchain_miners_total {miners_total}") + + lines.append("# HELP rustchain_miners_active Currently active miners") + lines.append("# TYPE rustchain_miners_active gauge") + lines.append(f"rustchain_miners_active {miners_active}") + + lines.append("# HELP rustchain_miner_antiquity_multiplier Per-miner antiquity reward multiplier") + lines.append("# TYPE rustchain_miner_antiquity_multiplier gauge") + with _miner_multipliers_lock: + for mid, mult in sorted(_miner_multipliers.items()): + lines.append( + f'rustchain_miner_antiquity_multiplier{{miner="{mid}"}} {mult}' + ) + + lines.append("# HELP rustchain_last_scrape_timestamp Unix timestamp of last successful scrape") + lines.append("# TYPE rustchain_last_scrape_timestamp gauge") + lines.append(f"rustchain_last_scrape_timestamp {last_scrape}") + + body = "\n".join(lines) + "\n" + return Response(body, mimetype="text/plain; version=0.0.4; charset=utf-8") + + +@app.route("/health") +def health(): + """Exporter health check.""" + with _metrics_lock: + status = "healthy" if _metrics["node_up"] else "unhealthy" + return {"status": status, "exporter": "rustchain-prometheus-exporter"} + + +if __name__ == "__main__": + logger.info("Starting RustChain Prometheus exporter") + logger.info("Node URL: %s", NODE_URL) + logger.info("Metrics: http://%s:%s/metrics", EXPORTER_HOST, EXPORTER_PORT) + + scraper = Thread(target=_scraper_loop, daemon=True) + scraper.start() + + app.run(host=EXPORTER_HOST, port=EXPORTER_PORT, debug=False) diff --git a/tools/prometheus-exporter/test_exporter.py b/tools/prometheus-exporter/test_exporter.py new file mode 100644 index 000000000..5f8adfa33 --- /dev/null +++ b/tools/prometheus-exporter/test_exporter.py @@ -0,0 +1,131 @@ +"""Tests for tools/prometheus-exporter/rustchain_exporter.py""" + +import json +import os +import sys +import pytest + +# Ensure the exporter module is importable +EXPORTER_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(EXPORTER_DIR, "..", "tools", "prometheus-exporter")) + +from rustchain_exporter import app, _metrics, _miner_multipliers + + +@pytest.fixture +def client(): + app.config["TESTING"] = True + with app.test_client() as c: + yield c + + +class TestMetricsEndpoint: + """Test the /metrics Prometheus endpoint.""" + + def test_metrics_returns_200(self, client): + resp = client.get("/metrics") + assert resp.status_code == 200 + + def test_metrics_content_type(self, client): + resp = client.get("/metrics") + assert "text/plain" in resp.content_type + + def test_metrics_has_node_up(self, client): + resp = client.get("/metrics") + body = resp.data.decode() + assert "rustchain_node_up" in body + + def test_metrics_has_epoch(self, client): + resp = client.get("/metrics") + body = resp.data.decode() + assert "rustchain_epoch" in body + + def test_metrics_has_miners_total(self, client): + resp = client.get("/metrics") + body = resp.data.decode() + assert "rustchain_miners_total" in body + + def test_metrics_has_version_info(self, client): + resp = client.get("/metrics") + body = resp.data.decode() + assert "rustchain_node_version_info" in body + + def test_metrics_has_last_scrape(self, client): + resp = client.get("/metrics") + body = resp.data.decode() + assert "rustchain_last_scrape_timestamp" in body + + def test_metrics_has_miner_antiquity_multiplier_header(self, client): + resp = client.get("/metrics") + body = resp.data.decode() + assert "rustchain_miner_antiquity_multiplier" in body + + +class TestMinerMultipliers: + """Test per-miner antiquity multiplier rendering.""" + + def test_no_miners_still_has_header(self, client): + _miner_multipliers.clear() + resp = client.get("/metrics") + body = resp.data.decode() + assert "# HELP rustchain_miner_antiquity_multiplier" in body + + def test_single_miner_rendered(self, client): + _miner_multipliers.clear() + _miner_multipliers["miner_abc123"] = 1.5 + resp = client.get("/metrics") + body = resp.data.decode() + assert 'miner="miner_abc123"' in body + assert "1.5" in body + + def test_multiple_miners_sorted(self, client): + _miner_multipliers.clear() + _miner_multipliers["z_miner"] = 2.0 + _miner_multipliers["a_miner"] = 0.5 + resp = client.get("/metrics") + body = resp.data.decode() + a_pos = body.index("a_miner") + z_pos = body.index("z_miner") + assert a_pos < z_pos, "Miners should be sorted alphabetically" + + +class TestHealthEndpoint: + """Test the /health endpoint.""" + + def test_health_returns_200(self, client): + resp = client.get("/health") + assert resp.status_code == 200 + + def test_health_has_status(self, client): + resp = client.get("/health") + data = resp.get_json() + assert "status" in data + + def test_health_has_exporter_name(self, client): + resp = client.get("/health") + data = resp.get_json() + assert data.get("exporter") == "rustchain-prometheus-exporter" + + +class TestPrometheusFormat: + """Verify Prometheus exposition format compliance.""" + + def test_has_help_and_type_comments(self, client): + resp = client.get("/metrics") + body = resp.data.decode() + assert "# HELP rustchain_node_up" in body + assert "# TYPE rustchain_node_up gauge" in body + + def test_valid_gauge_syntax(self, client): + _metrics["node_up"] = 1 + resp = client.get("/metrics") + body = resp.data.decode() + lines = body.strip().split("\n") + gauge_lines = [ + l for l in lines if l.startswith("rustchain_") and not l.startswith("#") + ] + for line in gauge_lines: + parts = line.split() + assert len(parts) == 2 or (len(parts) >= 2 and "{" in parts[0]), ( + f"Invalid gauge line: {line}" + )