diff --git a/DOCKER_QUICKSTART.md b/DOCKER_QUICKSTART.md index ebad38ef..1ebf3b06 100644 --- a/DOCKER_QUICKSTART.md +++ b/DOCKER_QUICKSTART.md @@ -146,6 +146,33 @@ docker logs -f lean-spec-node docker stop lean-spec-node ``` +### 8. Metrics & Observability + +Enable Prometheus metrics and health endpoint: + +```bash +docker run --rm \ + -v /path/to/genesis:/app/data:ro \ + -p 9000:9000 \ + -p 8008:8008 \ + lean-spec:node \ + --genesis /app/data/config.yaml \ + --metrics-port 8008 \ + --log-format json +``` + +You can now access: +- Metrics: `curl http://localhost:8008/metrics` +- Health: `curl http://localhost:8008/health` + +#### Full Observability Stack + +A complete setup with Prometheus and Grafana is available: + +```bash +docker-compose -f docker-compose.observability.yml up +``` + ## Using with lean-quickstart Genesis If you have the lean-quickstart repo with generated genesis: @@ -180,6 +207,9 @@ docker run --rm \ | `--checkpoint-sync-url URL` | URL for checkpoint sync (e.g., `http://host:5052`) | No | | `--validator-keys PATH` | Path to validator keys directory | No | | `--node-id ID` | Node identifier for validator assignment (default: `lean_spec_0`) | No | +| `--metrics-port PORT` | Port to expose Prometheus metrics and health endpoint | No | +| `--log-format {text,json}` | Log output format (default: `text`) | No | +| `--log-level LEVEL` | Log level (default: `INFO`) | No | | `-v, --verbose` | Enable debug logging | No | Run `docker run lean-spec:node --help` to see all available options. diff --git a/Dockerfile b/Dockerfile index 37761902..bf22f432 100644 --- a/Dockerfile +++ b/Dockerfile @@ -118,6 +118,9 @@ RUN mkdir -p /app/data # Expose p2p port EXPOSE 9000 +# Expose metrics port +EXPOSE 8008 + # Set entrypoint to lean_spec directly # Users can pass CLI arguments directly: docker run lean_spec --genesis /data/config.yaml --bootnode ... ENTRYPOINT ["uv", "run", "python", "-m", "lean_spec"] diff --git a/docker-compose.observability.yml b/docker-compose.observability.yml new file mode 100644 index 00000000..95b1f37d --- /dev/null +++ b/docker-compose.observability.yml @@ -0,0 +1,36 @@ +version: '3.8' + +services: + leanspec-node: + build: + context: . + target: node + ports: + - "9000:9000" + - "8008:8008" + command: > + --genesis /app/data/config.yaml + --metrics-port 8008 + --log-format json + volumes: + - ./data:/app/data + + prometheus: + image: prom/prometheus:latest + volumes: + - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + + grafana: + image: grafana/grafana:latest + ports: + - "3000:3000" + environment: + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + volumes: + - grafana-storage:/var/lib/grafana + +volumes: + grafana-storage: diff --git a/observability/grafana/dashboard.json b/observability/grafana/dashboard.json new file mode 100644 index 00000000..85c16247 --- /dev/null +++ b/observability/grafana/dashboard.json @@ -0,0 +1,194 @@ +{ + "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": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "leanspec_slot_current", + "format": "time_series", + "instant": false, + "range": true, + "refId": "A" + } + ], + "title": "Current Slot", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "leanspec_peers_connected", + "format": "time_series", + "instant": false, + "range": true, + "refId": "A" + } + ], + "title": "Connected Peers", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "h": 8, + "w": 24, + "y": 8, + "x": 0 + }, + "id": 3, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "rate(leanspec_blocks_imported_total[5m])", + "legendFormat": "Blocks/sec", + "refId": "A" + } + ], + "title": "Block Import Rate", + "type": "timeseries" + } + ], + "refresh": "5s", + "schemaVersion": 38, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "Prometheus", + "value": "prometheus" + }, + "hide": 0, + "includeAll": false, + "label": "Datasource", + "multi": false, + "name": "datasource", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "skipUrl": false, + "type": "datasource" + } + ] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "leanSpec Node Dashboard", + "uid": "leanspec-node", + "version": 1, + "weekStart": "" +} diff --git a/observability/prometheus.yml b/observability/prometheus.yml new file mode 100644 index 00000000..1fd8c158 --- /dev/null +++ b/observability/prometheus.yml @@ -0,0 +1,7 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'leanspec-node' + static_configs: + - targets: ['leanspec-node:8008'] diff --git a/pyproject.toml b/pyproject.toml index edb94ddd..c4bba644 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,8 @@ dependencies = [ "numpy>=2.0.0,<3", "aioquic>=1.2.0,<2", "pyyaml>=6.0.0,<7", + "prometheus-client>=0.21.0,<1", + "python-json-logger>=3.2.0,<4", ] [project.license] diff --git a/src/lean_spec/__main__.py b/src/lean_spec/__main__.py index a3a1ae92..ce5aa7f4 100644 --- a/src/lean_spec/__main__.py +++ b/src/lean_spec/__main__.py @@ -29,6 +29,7 @@ import os import sys import time +from pythonjsonlogger import jsonlogger from pathlib import Path from typing import Final @@ -356,16 +357,29 @@ def format(self, record: logging.LogRecord) -> str: return f"{colored_time} {levelname} {name}: {message}" -def setup_logging(verbose: bool = False, no_color: bool = False) -> None: - """Configure logging for the node with optional colors.""" - level = logging.DEBUG if verbose else logging.INFO +def setup_logging( + verbose: bool = False, + no_color: bool = False, + log_format: str = "text", + log_level: str | None = None, +) -> None: + """Configure logging for the node.""" + if log_level: + level = getattr(logging, log_level.upper(), logging.INFO) + else: + level = logging.DEBUG if verbose else logging.INFO # Create handler handler = logging.StreamHandler() handler.setLevel(level) + # Use JSON formatter if requested + if log_format == "json": + formatter = jsonlogger.JsonFormatter( + "%(asctime)s %(levelname)s %(name)s %(message)s" + ) # Use colored formatter unless disabled - if no_color: + elif no_color: formatter = logging.Formatter( "%(asctime)s %(levelname)-8s %(name)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", @@ -390,6 +404,7 @@ async def run_node( node_id: str = "lean_spec_0", genesis_time_now: bool = False, is_aggregator: bool = False, + metrics_port: int | None = None, ) -> None: """ Run the lean consensus node. @@ -506,6 +521,11 @@ async def run_node( # - Starts from block 0 with initial validator set # - Must process every block to reach current head # - Only practical for new or small networks + # Create API config if requested + api_config = None + if metrics_port is not None: + api_config = ApiServerConfig(port=metrics_port) + node: Node | None if checkpoint_sync_url is not None: node = await _init_from_checkpoint( @@ -515,6 +535,13 @@ async def run_node( validator_registry=validator_registry, is_aggregator=is_aggregator, ) + if node is not None: + node.api_server = ApiServer( + config=api_config, + store_getter=lambda: node.sync_service.store if node else None, + sync_service_getter=lambda: node.sync_service if node else None, + metrics_getter=lambda: node.metrics if node else None, + ) if api_config else None if node is None: # Checkpoint sync failed. Exit rather than falling back. # @@ -522,12 +549,17 @@ async def run_node( # They explicitly requested checkpoint sync for a reason. return else: - node = _init_from_genesis( - genesis=genesis, + node_conf = NodeConfig( + genesis_time=genesis.genesis_time, + validators=genesis.to_validators(), event_source=event_source, + network=event_source.reqresp_client, validator_registry=validator_registry, + fork_digest=GOSSIP_FORK_DIGEST, is_aggregator=is_aggregator, + api_config=api_config, ) + node = Node.from_genesis(node_conf) logger.info("Node initialized, peer_id=%s", event_source.connection_manager.peer_id) @@ -662,10 +694,27 @@ def main() -> None: action="store_true", help="Enable aggregator mode (node performs attestation aggregation)", ) + parser.add_argument( + "--metrics-port", + type=int, + default=None, + help="Port to expose Prometheus metrics and health endpoint (default: disabled)", + ) + parser.add_argument( + "--log-format", + choices=["text", "json"], + default="text", + help="Log output format (default: text)", + ) + parser.add_argument( + "--log-level", + default=os.environ.get("LEANSPEC_LOG_LEVEL", "INFO"), + help="Log level (default: INFO or LEANSPEC_LOG_LEVEL env var)", + ) args = parser.parse_args() - setup_logging(args.verbose, args.no_color) + setup_logging(args.verbose, args.no_color, args.log_format, args.log_level) # Use asyncio.run with proper task cancellation on interrupt. # This ensures all tasks are cancelled and resources are released. @@ -680,6 +729,7 @@ def main() -> None: node_id=args.node_id, genesis_time_now=args.genesis_time_now, is_aggregator=args.is_aggregator, + metrics_port=args.metrics_port, ) ) except KeyboardInterrupt: diff --git a/src/lean_spec/subspecs/api/endpoints/health.py b/src/lean_spec/subspecs/api/endpoints/health.py index cd6206fb..3a938f07 100644 --- a/src/lean_spec/subspecs/api/endpoints/health.py +++ b/src/lean_spec/subspecs/api/endpoints/health.py @@ -2,32 +2,50 @@ from __future__ import annotations -import json -from typing import Final - from aiohttp import web -STATUS_HEALTHY: Final = "healthy" -"""Fixed healthy status returned by the health endpoint.""" - -SERVICE_NAME: Final = "lean-rpc-api" -"""Fixed service identifier returned by the health endpoint.""" - -async def handle(_request: web.Request) -> web.Response: +async def handle(request: web.Request) -> web.Response: """ Handle health check request. Returns server health status to indicate the service is operational. Response: JSON object with fields: - - status (string): Always healthy when the endpoint is reachable. - - service (string): Fixed identifier "lean-rpc-api". + - status (string): "ok" + - slot (int): Current head slot + - synced (boolean): Whether the node is synced Status Codes: - 200 OK: Server is running. + 200 OK: Server is synced. + 503 Service Unavailable: Server is not synced. """ - return web.Response( - body=json.dumps({"status": STATUS_HEALTHY, "service": SERVICE_NAME}), - content_type="application/json", + store_getter = request.app.get("store_getter") + store = store_getter() if store_getter else None + + if store is None: + return web.json_response( + {"status": "error", "message": "Store not available"}, + status=503, + ) + + head_slot = int(store.blocks[store.head].slot) + + # Determine sync status from SyncService state if available + sync_service_getter = request.app.get("sync_service_getter") + sync_service = sync_service_getter() if sync_service_getter else None + + if sync_service is not None: + synced = sync_service.state.is_synced + else: + # Fallback: if no sync service is available, assume synced + synced = True + + return web.json_response( + { + "status": "ok", + "slot": head_slot, + "synced": synced, + }, + status=200 if synced else 503, ) diff --git a/src/lean_spec/subspecs/api/endpoints/metrics.py b/src/lean_spec/subspecs/api/endpoints/metrics.py new file mode 100644 index 00000000..8e415567 --- /dev/null +++ b/src/lean_spec/subspecs/api/endpoints/metrics.py @@ -0,0 +1,28 @@ +"""Metrics endpoint implementation.""" + +from __future__ import annotations + +from aiohttp import web +from prometheus_client import generate_latest, CONTENT_TYPE_LATEST + + +async def handle(request: web.Request) -> web.Response: + """ + Handle metrics request. + + Returns Prometheus metrics in text format. + Uses the Metrics instance registry if available, otherwise falls back + to the default global registry. + """ + metrics_getter = request.app.get("metrics_getter") + metrics = metrics_getter() if metrics_getter else None + + if metrics is not None: + body = generate_latest(metrics.registry) + else: + body = generate_latest() + + return web.Response( + body=body, + content_type=CONTENT_TYPE_LATEST, + ) diff --git a/src/lean_spec/subspecs/api/routes.py b/src/lean_spec/subspecs/api/routes.py index a06eefa5..b0900042 100644 --- a/src/lean_spec/subspecs/api/routes.py +++ b/src/lean_spec/subspecs/api/routes.py @@ -6,10 +6,11 @@ from aiohttp import web -from .endpoints import checkpoints, fork_choice, health, states +from .endpoints import checkpoints, fork_choice, health, states, metrics ROUTES: dict[str, Callable[[web.Request], Awaitable[web.Response]]] = { "/lean/v0/health": health.handle, + "/metrics": metrics.handle, "/lean/v0/states/finalized": states.handle_finalized, "/lean/v0/checkpoints/justified": checkpoints.handle_justified, "/lean/v0/fork_choice": fork_choice.handle, diff --git a/src/lean_spec/subspecs/api/server.py b/src/lean_spec/subspecs/api/server.py index 172a5d55..606bc160 100644 --- a/src/lean_spec/subspecs/api/server.py +++ b/src/lean_spec/subspecs/api/server.py @@ -68,6 +68,12 @@ class ApiServer: store_getter: Callable[[], Store | None] | None = None """Callable that returns the current Store instance.""" + sync_service_getter: Callable[[], object] | None = None + """Callable that returns the SyncService for health check state.""" + + metrics_getter: Callable[[], object] | None = None + """Callable that returns the Metrics instance for the /metrics endpoint.""" + _runner: web.AppRunner | None = field(default=None, init=False) """aiohttp application runner.""" @@ -87,8 +93,9 @@ async def start(self) -> None: app = web.Application() - # Store the store_getter in app for handlers that need store access app["store_getter"] = self.store_getter + app["sync_service_getter"] = self.sync_service_getter + app["metrics_getter"] = self.metrics_getter # Add all routes app.add_routes(_routes) diff --git a/src/lean_spec/subspecs/metrics.py b/src/lean_spec/subspecs/metrics.py new file mode 100644 index 00000000..e4b515f6 --- /dev/null +++ b/src/lean_spec/subspecs/metrics.py @@ -0,0 +1,94 @@ +""" +Prometheus metrics for the lean consensus node. + +Provides instrumentation for monitoring node health, protocol behavior, +and synchronization status. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field + +from prometheus_client import CollectorRegistry, Counter, Gauge + + +def _make_registry() -> CollectorRegistry: + """Create a fresh Prometheus collector registry.""" + return CollectorRegistry() + + +@dataclass(frozen=True, slots=True) +class Metrics: + """ + Prometheus metrics registry for leanspec-node. + + Each Metrics instance owns its own CollectorRegistry so that multiple + instances (e.g. in tests) don't collide on the global registry. + """ + + registry: CollectorRegistry = field(default_factory=_make_registry) + """Prometheus collector registry for this metrics instance.""" + + slot_current: Gauge = field(init=False) + epoch_current: Gauge = field(init=False) + peers_connected: Gauge = field(init=False) + blocks_imported_total: Counter = field(init=False) + attestations_processed_total: Counter = field(init=False) + sync_distance: Gauge = field(init=False) + process_start_time_seconds: Gauge = field(init=False) + + def __post_init__(self) -> None: + """Initialize all metrics on the instance registry.""" + reg = self.registry + + object.__setattr__( + self, + "slot_current", + Gauge("leanspec_slot_current", "Current slot number", registry=reg), + ) + object.__setattr__( + self, + "epoch_current", + Gauge("leanspec_epoch_current", "Current epoch", registry=reg), + ) + object.__setattr__( + self, + "peers_connected", + Gauge("leanspec_peers_connected", "Number of connected peers", registry=reg), + ) + object.__setattr__( + self, + "blocks_imported_total", + Counter("leanspec_blocks_imported_total", "Cumulative blocks imported", registry=reg), + ) + object.__setattr__( + self, + "attestations_processed_total", + Counter( + "leanspec_attestations_processed_total", + "Cumulative attestations processed", + registry=reg, + ), + ) + object.__setattr__( + self, + "sync_distance", + Gauge("leanspec_sync_distance", "Slots behind head (0 when synced)", registry=reg), + ) + object.__setattr__( + self, + "process_start_time_seconds", + Gauge( + "leanspec_process_start_time_seconds", + "Unix timestamp of node startup", + registry=reg, + ), + ) + + self.process_start_time_seconds.set(time.time()) + + @classmethod + def create(cls) -> Metrics: + """Create and initialize a new metrics registry.""" + return cls() diff --git a/src/lean_spec/subspecs/node/node.py b/src/lean_spec/subspecs/node/node.py index b221a64a..e235307e 100644 --- a/src/lean_spec/subspecs/node/node.py +++ b/src/lean_spec/subspecs/node/node.py @@ -1,11 +1,5 @@ """ Consensus node orchestrator. - -Wires together all services and runs them with structured concurrency. - -The Node is the top-level entry point for a minimal Ethereum consensus client. -It initializes all components from genesis configuration and coordinates their -concurrent execution. """ from __future__ import annotations @@ -17,14 +11,16 @@ from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path -from typing import Final +from typing import TYPE_CHECKING, Final from lean_spec.subspecs.api import ApiServer, ApiServerConfig +from lean_spec.subspecs.metrics import Metrics from lean_spec.subspecs.chain import SlotClock from lean_spec.subspecs.chain.clock import Interval from lean_spec.subspecs.chain.config import ( ATTESTATION_COMMITTEE_COUNT, INTERVALS_PER_SLOT, + JUSTIFICATION_LOOKBACK_SLOTS, SECONDS_PER_SLOT, ) from lean_spec.subspecs.chain.service import ChainService @@ -157,6 +153,9 @@ class Node: database: Database | None = field(default=None) """Optional database reference for lifecycle management.""" + metrics: Metrics = field(default_factory=Metrics.create) + """Prometheus metrics registry.""" + _shutdown: asyncio.Event = field(default_factory=asyncio.Event) """Event signaling shutdown request.""" @@ -239,6 +238,7 @@ def from_genesis(cls, config: NodeConfig) -> Node: # # Sync service is the hub. It owns the store and coordinates updates. # Chain and network services communicate through it. + metrics = Metrics.create() sync_service = SyncService( store=store, peer_manager=peer_manager, @@ -248,6 +248,7 @@ def from_genesis(cls, config: NodeConfig) -> Node: database=database, is_aggregator=config.is_aggregator, genesis_start=True, + metrics=metrics, ) chain_service = ChainService(sync_service=sync_service, clock=clock) @@ -271,6 +272,8 @@ def from_genesis(cls, config: NodeConfig) -> Node: api_server = ApiServer( config=config.api_config, store_getter=lambda: sync_service.store, + sync_service_getter=lambda: sync_service, + metrics_getter=lambda: metrics, ) # Create validator service if registry provided. @@ -314,6 +317,7 @@ async def publish_block_wrapper(block: SignedBlockWithAttestation) -> None: api_server=api_server, validator_service=validator_service, database=database, + metrics=sync_service.metrics if sync_service.metrics else Metrics.create(), ) @staticmethod @@ -472,6 +476,17 @@ async def _log_justified_finalized_periodically(self) -> None: f = store.latest_finalized j_root = j.root.hex() if hasattr(j.root, "hex") else str(j.root) f_root = f.root.hex() if hasattr(f.root, "hex") else str(f.root) + + head_slot = int(j.slot) + epoch = head_slot // int(JUSTIFICATION_LOOKBACK_SLOTS) + + log_extra = { + "slot": head_slot, + "epoch": epoch, + "peers": peers_connected, + "peer_id": str(self.network_service.peer_id) if hasattr(self.network_service, "peer_id") else None, + } + logger.info("=" * 64) logger.info( "Peers=%s | Justified slot=%s root=%s | Finalized slot=%s root=%s", @@ -480,9 +495,22 @@ async def _log_justified_finalized_periodically(self) -> None: j_root, f.slot, f_root, + extra=log_extra, ) logger.info("=" * 64) + # Update metrics + self.metrics.slot_current.set(float(head_slot)) + self.metrics.epoch_current.set(float(epoch)) + self.metrics.peers_connected.set(float(peers_connected)) + + network_finalized = self.sync_service.peer_manager.get_network_finalized_slot() + if network_finalized is not None: + distance = max(0, int(network_finalized) - head_slot) + self.metrics.sync_distance.set(float(distance)) + else: + self.metrics.sync_distance.set(0.0) + async def _wait_shutdown(self) -> None: """ Wait for shutdown signal then stop services. diff --git a/src/lean_spec/subspecs/sync/service.py b/src/lean_spec/subspecs/sync/service.py index ba7e3b39..b9b9b922 100644 --- a/src/lean_spec/subspecs/sync/service.py +++ b/src/lean_spec/subspecs/sync/service.py @@ -54,6 +54,7 @@ from lean_spec.subspecs.networking.reqresp.message import Status from lean_spec.subspecs.networking.transport.peer_id import PeerId from lean_spec.subspecs.ssz.hash import hash_tree_root +from lean_spec.subspecs.metrics import Metrics from .backfill_sync import BackfillSync, NetworkRequester from .block_cache import BlockCache @@ -162,6 +163,9 @@ class SyncService: ) """Block processor function. Defaults to the store's block processing.""" + metrics: Metrics | None = field(default=None) + """Optional Prometheus metrics registry.""" + _publish_agg_fn: Callable[[SignedAggregatedAttestation], Coroutine[Any, Any, None]] = field( default=_noop_publish_agg ) @@ -266,6 +270,8 @@ def _process_block_wrapper( # # We only count blocks that pass validation and update the store. self._blocks_processed += 1 + if self.metrics: + self.metrics.blocks_imported_total.inc() # Persist block and state to database if available. # @@ -514,6 +520,8 @@ async def on_gossip_attestation( signed_attestation=attestation, is_aggregator=is_aggregator_role, ) + if self.metrics: + self.metrics.attestations_processed_total.inc() logger.info( "Attestation from peer %s slot=%s validator=%s: validation and signature ok", peer_str, @@ -566,6 +574,8 @@ async def on_gossip_aggregated_attestation( try: self.store = self.store.on_gossip_aggregated_attestation(signed_attestation) + if self.metrics: + self.metrics.attestations_processed_total.inc() logger.info( "Aggregated attestation from peer %s slot=%s: validation and signature ok", peer_str, diff --git a/tests/api/endpoints/test_health.py b/tests/api/endpoints/test_health.py index 8685d789..d807917a 100644 --- a/tests/api/endpoints/test_health.py +++ b/tests/api/endpoints/test_health.py @@ -31,7 +31,10 @@ def test_health_response_structure(server_url: str) -> None: data = response.json() assert "status" in data - assert data["status"] == health.STATUS_HEALTHY + assert data["status"] == "ok" - assert "service" in data - assert data["service"] == health.SERVICE_NAME + assert "slot" in data + assert isinstance(data["slot"], int) + + assert "synced" in data + assert isinstance(data["synced"], bool) diff --git a/uv.lock b/uv.lock index 84fc8429..f44566f4 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" [manifest] @@ -880,7 +880,9 @@ dependencies = [ { name = "httpx" }, { name = "lean-multisig-py" }, { name = "numpy" }, + { name = "prometheus-client" }, { name = "pydantic" }, + { name = "python-json-logger" }, { name = "pyyaml" }, { name = "typing-extensions" }, ] @@ -940,7 +942,9 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.0,<1" }, { name = "lean-multisig-py", git = "https://github.com/anshalshukla/leanMultisig-py?branch=devnet2" }, { name = "numpy", specifier = ">=2.0.0,<3" }, + { name = "prometheus-client", specifier = ">=0.21.0,<1" }, { name = "pydantic", specifier = ">=2.12.0,<3" }, + { name = "python-json-logger", specifier = ">=3.2.0,<4" }, { name = "pyyaml", specifier = ">=6.0.0,<7" }, { name = "typing-extensions", specifier = ">=4.4" }, ] @@ -1514,6 +1518,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "prometheus-client" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" }, +] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -1916,6 +1929,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-json-logger" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/de/d3144a0bceede957f961e975f3752760fbe390d57fbe194baf709d8f1f7b/python_json_logger-3.3.0.tar.gz", hash = "sha256:12b7e74b17775e7d565129296105bbe3910842d9d0eb083fc83a6a617aa8df84", size = 16642, upload-time = "2025-03-07T07:08:27.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/20/0f2523b9e50a8052bc6a8b732dfc8568abbdc42010aef03a2d750bdab3b2/python_json_logger-3.3.0-py3-none-any.whl", hash = "sha256:dd980fae8cffb24c13caf6e158d3d61c0d6d22342f932cb6e9deedab3d35eec7", size = 15163, upload-time = "2025-03-07T07:08:25.627Z" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3"