Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions tools/prometheus-exporter/README.md
Original file line number Diff line number Diff line change
@@ -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
```
2 changes: 2 additions & 0 deletions tools/prometheus-exporter/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
requests>=2.28.0
flask>=2.3.0
180 changes: 180 additions & 0 deletions tools/prometheus-exporter/rustchain_exporter.py
Original file line number Diff line number Diff line change
@@ -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)
131 changes: 131 additions & 0 deletions tools/prometheus-exporter/test_exporter.py
Original file line number Diff line number Diff line change
@@ -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}"
)
Loading