From 709881be867db3cea891c4796c2bf4aa1eb1e3e9 Mon Sep 17 00:00:00 2001 From: juvdiaz Date: Mon, 29 Jun 2026 19:22:23 -0600 Subject: [PATCH] Add blockchain devnet metrics dashboard --- README.md | 4 +- README.md.tmpl | 4 +- .../homelab-blockchain-devnet.json | 247 ++++++++++++++++++ infra/blockchain-devnet/README.md | 24 ++ infra/blockchain-devnet/docker-compose.yml | 17 ++ infra/blockchain-devnet/metrics/exporter.py | 97 +++++++ .../prometheus-scrape.example.yml | 6 + 7 files changed, 397 insertions(+), 2 deletions(-) create mode 100644 bootstrap/platform/grafana-dashboards/homelab-blockchain-devnet.json create mode 100644 infra/blockchain-devnet/metrics/exporter.py create mode 100644 infra/blockchain-devnet/prometheus-scrape.example.yml diff --git a/README.md b/README.md index f4613ba..548b792 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,9 @@ and recent repo-server/application-controller errors. `grafana-dashboards` manages repo-provisioned Grafana dashboards under `bootstrap/platform/grafana-dashboards`. `./jeannie up` provisions them through the platform stack; `./jeannie grafana-dashboards apply` -rebuilds just the dashboard ConfigMaps and restarts Grafana. +rebuilds just the dashboard ConfigMaps and restarts Grafana. The optional +blockchain devnet dashboard uses metrics from +`infra/blockchain-devnet/metrics/exporter.py`. `cert-check` verifies public DNS, TLS certificate expiry, public website/Gitea HTTP status, and DuckDNS-to-OCI edge IP drift from the canonical inventory. diff --git a/README.md.tmpl b/README.md.tmpl index e37618f..a9fd629 100644 --- a/README.md.tmpl +++ b/README.md.tmpl @@ -346,7 +346,9 @@ and recent repo-server/application-controller errors. `grafana-dashboards` manages repo-provisioned Grafana dashboards under `bootstrap/platform/grafana-dashboards`. `./{{ main_script }} up` provisions them through the platform stack; `./{{ main_script }} grafana-dashboards apply` -rebuilds just the dashboard ConfigMaps and restarts Grafana. +rebuilds just the dashboard ConfigMaps and restarts Grafana. The optional +blockchain devnet dashboard uses metrics from +`infra/blockchain-devnet/metrics/exporter.py`. `cert-check` verifies public DNS, TLS certificate expiry, public website/Gitea HTTP status, and DuckDNS-to-OCI edge IP drift from the canonical inventory. diff --git a/bootstrap/platform/grafana-dashboards/homelab-blockchain-devnet.json b/bootstrap/platform/grafana-dashboards/homelab-blockchain-devnet.json new file mode 100644 index 0000000..4725f6d --- /dev/null +++ b/bootstrap/platform/grafana-dashboards/homelab-blockchain-devnet.json @@ -0,0 +1,247 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "0": { + "text": "down" + }, + "1": { + "text": "up" + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "homelab_blockchain_rpc_up", + "legendFormat": "rpc", + "refId": "A" + } + ], + "title": "RPC Health", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "homelab_blockchain_latest_block", + "legendFormat": "latest block", + "refId": "A" + } + ], + "title": "Latest Block", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "homelab_blockchain_gas_price_wei", + "legendFormat": "wei", + "refId": "A" + } + ], + "title": "Gas Price", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "homelab_blockchain_peer_count", + "legendFormat": "peers", + "refId": "A" + } + ], + "title": "Peer Count", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 8 + }, + "id": 5, + "targets": [ + { + "expr": "homelab_blockchain_rpc_latency_seconds", + "legendFormat": "{{ method }}", + "refId": "A" + } + ], + "title": "RPC Latency", + "type": "timeseries" + } + ], + "refresh": "10s", + "schemaVersion": 39, + "tags": [ + "homelab", + "blockchain" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Homelab Blockchain Devnet", + "uid": "homelab-blockchain-devnet", + "version": 1, + "weekStart": "" +} diff --git a/infra/blockchain-devnet/README.md b/infra/blockchain-devnet/README.md index 0f4a597..2387d59 100644 --- a/infra/blockchain-devnet/README.md +++ b/infra/blockchain-devnet/README.md @@ -37,6 +37,29 @@ Use the gateway when you want to practice RPC protection patterns such as method restriction, rate limiting, CORS headers, and request logging. Keep the raw Anvil RPC for direct local development. +The Prometheus metrics endpoint is available at: + +```text +http://127.0.0.1:9109/metrics +``` + +Metrics include: + +- `homelab_blockchain_rpc_up` +- `homelab_blockchain_chain_id` +- `homelab_blockchain_latest_block` +- `homelab_blockchain_peer_count` +- `homelab_blockchain_gas_price_wei` +- `homelab_blockchain_rpc_latency_seconds` + +Use `prometheus-scrape.example.yml` as the scrape target shape. The repo also +includes the `Homelab Blockchain Devnet` Grafana dashboard under +`bootstrap/platform/grafana-dashboards`; rebuild dashboard ConfigMaps with: + +```bash +./jeannie grafana-dashboards apply +``` + From another LAN host, use the Debian host IP: ```text @@ -61,6 +84,7 @@ curl -sS -X POST http://127.0.0.1:8545 \ BLOCKCHAIN_RPC_PORT=8545 BLOCKCHAIN_RPC_GATEWAY_PORT=18545 BLOCKCHAIN_EXPLORER_PORT=8089 +BLOCKCHAIN_METRICS_PORT=9109 BLOCKCHAIN_CHAIN_ID=31337 BLOCKCHAIN_BLOCK_TIME=2 ``` diff --git a/infra/blockchain-devnet/docker-compose.yml b/infra/blockchain-devnet/docker-compose.yml index e14ae29..656e5c9 100644 --- a/infra/blockchain-devnet/docker-compose.yml +++ b/infra/blockchain-devnet/docker-compose.yml @@ -39,3 +39,20 @@ services: volumes: - ./nginx/rpc-gateway.conf:/etc/nginx/conf.d/default.conf:ro restart: unless-stopped + + metrics: + image: python:3.12-alpine + container_name: homelab-blockchain-metrics + depends_on: + - anvil + environment: + BLOCKCHAIN_METRICS_RPC_URL: http://anvil:8545 + BLOCKCHAIN_METRICS_PORT: "9109" + ports: + - "${BLOCKCHAIN_METRICS_PORT:-9109}:9109" + volumes: + - ./metrics/exporter.py:/app/exporter.py:ro + command: + - python + - /app/exporter.py + restart: unless-stopped diff --git a/infra/blockchain-devnet/metrics/exporter.py b/infra/blockchain-devnet/metrics/exporter.py new file mode 100644 index 0000000..28af607 --- /dev/null +++ b/infra/blockchain-devnet/metrics/exporter.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +import json +import os +import time +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, HTTPServer + +RPC_URL = os.environ.get("BLOCKCHAIN_METRICS_RPC_URL", "http://anvil:8545") +LISTEN_ADDR = os.environ.get("BLOCKCHAIN_METRICS_ADDR", "0.0.0.0") +LISTEN_PORT = int(os.environ.get("BLOCKCHAIN_METRICS_PORT", "9109")) + + +def rpc(method): + started = time.monotonic() + body = json.dumps( + {"jsonrpc": "2.0", "method": method, "params": [], "id": 1} + ).encode("utf-8") + request = urllib.request.Request( + RPC_URL, + data=body, + headers={"content-type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=3) as response: + payload = json.loads(response.read().decode("utf-8")) + if "error" in payload: + raise RuntimeError(payload["error"]) + return payload.get("result"), time.monotonic() - started + + +def hex_to_int(value): + if isinstance(value, str) and value.startswith("0x"): + return int(value, 16) + return int(value) + + +def collect_metrics(): + metrics = [] + latencies = {} + values = {} + up = 1 + + for method in ["eth_chainId", "eth_blockNumber", "net_peerCount", "eth_gasPrice"]: + try: + result, latency = rpc(method) + latencies[method] = latency + values[method] = hex_to_int(result) + except (OSError, RuntimeError, urllib.error.URLError, ValueError): + up = 0 + latencies[method] = 0 + + metrics.append("# HELP homelab_blockchain_rpc_up Blockchain RPC health.") + metrics.append("# TYPE homelab_blockchain_rpc_up gauge") + metrics.append(f"homelab_blockchain_rpc_up {up}") + + gauge_map = { + "eth_chainId": "homelab_blockchain_chain_id", + "eth_blockNumber": "homelab_blockchain_latest_block", + "net_peerCount": "homelab_blockchain_peer_count", + "eth_gasPrice": "homelab_blockchain_gas_price_wei", + } + for method, metric_name in gauge_map.items(): + if method in values: + metrics.append(f"# TYPE {metric_name} gauge") + metrics.append(f"{metric_name} {values[method]}") + + metrics.append("# TYPE homelab_blockchain_rpc_latency_seconds gauge") + for method, latency in latencies.items(): + metrics.append( + f'homelab_blockchain_rpc_latency_seconds{{method="{method}"}} {latency:.6f}' + ) + + return "\n".join(metrics) + "\n" + + +class MetricsHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path not in ["/metrics", "/"]: + self.send_response(404) + self.end_headers() + return + + body = collect_metrics().encode("utf-8") + self.send_response(200) + self.send_header("content-type", "text/plain; version=0.0.4") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt, *args): + return + + +if __name__ == "__main__": + server = HTTPServer((LISTEN_ADDR, LISTEN_PORT), MetricsHandler) + server.serve_forever() diff --git a/infra/blockchain-devnet/prometheus-scrape.example.yml b/infra/blockchain-devnet/prometheus-scrape.example.yml new file mode 100644 index 0000000..8e798d9 --- /dev/null +++ b/infra/blockchain-devnet/prometheus-scrape.example.yml @@ -0,0 +1,6 @@ +- job_name: homelab-blockchain-devnet + static_configs: + - targets: + - 192.168.100.73:9109 + labels: + service: blockchain-devnet