Skip to content

Observability

A merod node is observable through two channels: a Prometheus /metrics endpoint for numeric time series, and structured tracing logs for event-level detail. This page documents both, grounded in the metric families the node actually registers.

The metrics service is mounted on the node’s HTTP server (the same listener as the admin API and JSON-RPC), at the fixed path /metrics. With the default [server] config that is:

Terminal window
curl http://127.0.0.1:2528/metrics

The body is Prometheus / OpenMetrics text format, produced by the prometheus-client crate. All node, sync, and HTTP metrics register against a single registry that the server encodes on each scrape.

Registered in crates/node/src/sync/prometheus_metrics.rs. protocol labels are sanitised against an allow-list (Snapshot, HashComparison, DeltaSync, SubtreePrefetch, LevelWise, BloomFilter, None), so cardinality stays bounded.

Metric Type Labels Meaning
sync_duration_seconds Histogram protocol, outcome End-to-end session duration (buckets 10ms–160s). outcome ∈ success, failure; failed attempts carry protocol="unknown".
sync_attempts Counter protocol Sync attempts started. Always protocol="unknown": the protocol is not chosen yet when an attempt starts, so compare it with sync_successes summed over protocol.
sync_successes Counter protocol Successful syncs.
sync_failures Counter protocol Failed syncs.
sync_protocol_selections Counter protocol Which protocol the adaptive selector chose.
sync_round_trips Counter protocol Request/response exchanges with the peer, added once per completed initiator session (HashComparison, LevelWise, DeltaSync).
sync_comparisons Counter protocol Tree-node hash comparisons per completed initiator session (HashComparison, LevelWise).
sync_entities_transferred Counter protocol Entities merged or pushed (HashComparison, LevelWise), snapshot records applied (Snapshot) or deltas received (DeltaSync), per completed initiator session. Near-zero in steady state.
sync_messages_sent Counter — Every sync stream message this node sends, any protocol and direction (counted at the transport, which does not know the protocol).
sync_bytes_sent Counter — Wire bytes of those messages, after encryption.

Safety / invariant counters (all should sit at zero in normal operation):

Metric Type Meaning
sync_snapshot_blocked Counter Snapshot attempt blocked on an already-initialised node. Any increment warrants investigation.
sync_verification_failures Counter Post-snapshot state-hash mismatch — possible corruption or non-determinism.
sync_buffer_drops Counter Deltas dropped from a bounded buffer (arriving faster than they apply). Any increment can mean catch-up sync is needed.

Per-CRDT-type merges, per-phase durations and LWW fallbacks are not exported: no production code path records them, and a series that is registered but never written reads a flat 0 that looks like health. Session costs come from the initiator’s totals, so a session that fails part-way records no cost.

Root-hash divergence (registered in crates/node/src/run.rs, bumped by the hash-heartbeat handler when a peer reports the same DAG heads as this node but a different storage root hash):

Series Type Meaning
sync_root_hash_divergence_detected_total_total Counter Every such observation, transient ones included. Every applied remote delta also sends a heartbeat, so under write load this rises with writes × peers while a concurrent sync apply is in flight — thousands per run on a fuzzy load test is normal. A load signal, not a failure signal: do not alert on it. (The doubled suffix is real: the name was registered ending in _total and the client library appends another.)
sync_root_hash_divergence_escalated_total Counter Divergences whose (ours, theirs) hash pair stayed unchanged past the persistence window (just under one 30s heartbeat period), counted once per stuck pair — the same transition that logs DIVERGENCE DETECTED at ERROR and kicks a recovery sync. Should stay at zero; any increment is a stuck split-brain worth investigating.

Registered in crates/node/src/node_metrics.rs. These are node-wide sums (no per-context label, by design — a node can host hundreds of contexts).

Metric Type Labels Meaning
delta_outcomes_total Counter outcome DAG delta-apply outcomes. outcome ∈ applied, pending, cascaded, duplicate, error, plus absorbed_leaf_future_schema / absorbed_snapshot_entity_future_schema for stragglers buffered by a stale-schema reader.
delta_cascade_size Histogram — Pending deltas unblocked when a parent finally lands.
delta_missing_parents_total Counter — Missing-parent requests issued to peers.
dag_heads_count Histogram — Concurrent DAG heads after each apply (buckets 1–256). Steady state is low (1–2).
dag_compaction_deltas_pruned_total Counter — Delta rows reclaimed by DAG compaction.
hc_leaf_drops_total Counter reason Leaves dropped by the apply-time membership check. reason ∈ unauthorized (expected under churn) / lookup_error (rare, an I/O signal).
delta_stores_count Gauge — Contexts with a live in-memory delta store.
sync_sessions_active Gauge — Contexts with an open snapshot-sync session (buffering deltas).
Metric Type Labels Meaning
governance_pending_contexts Gauge — Contexts holding at least one delta in the governance-pending buffer.
governance_pending_queue_depth Gauge — Sum of pending-buffer depths across contexts. Monotonic growth means the buffer cannot drain (governance has not caught up).
governance_drain_outcomes_total Counter outcome Per-delta drain outcomes. outcome ∈ applied, removed, never_member, rebuffered, dropped_max_attempts, lookup_error.
Metric Type Meaning
blob_cache_entries Gauge Blobs in the in-memory cache.
blob_cache_size_bytes Gauge Resident bytes across the blob cache.
blob_cache_evictions_age_total Counter Evicted for exceeding max age.
blob_cache_evictions_count_total Counter Evicted to keep entry count under the cap.
blob_cache_evictions_memory_total Counter Evicted to keep resident bytes under the cap.
Metric Type Labels Source Meaning
network_event_channel_depth Gauge — network channel Events waiting in the network→node channel. Sustained high depth means the node manager can’t keep up.
network_event_channel_dropped_total Counter — network channel Events dropped because the channel was full. Any increment can mean missed gossip / delayed sync.
network_event_channel_processing_latency_seconds Histogram — network channel Enqueue-to-dequeue latency.
http_requests_total Counter method, path, status server HTTP requests, by matched route template and status class (2xx/4xx/5xx).
http_request_duration_seconds Histogram method, path, status server Request latency.
merod_build_info Gauge version, peer_id node Constant-1 beacon. Confirms the scrape pipeline works and which build / identity answered.
process_resident_memory_bytes Gauge — node RSS of the merod process (Linux only; 0 elsewhere).
process_virtual_memory_bytes Gauge — node Virtual memory size (Linux only).
process_threads Gauge — node Thread count (Linux only).
process_open_fds Gauge — node Open file descriptors (Linux only).
process_cpu_seconds Counter — node User + system CPU time (Linux only). rate(process_cpu_seconds_total[1m]) is cores in use.
process_start_time_seconds Gauge — node Unix time the process started. A change between scrapes means the node restarted.

Sampled on the same 30s tick as the process gauges, off the async runtime.

Metric Type Labels Meaning
storage_disk_usage_bytes Gauge store Bytes allocated on disk under each store directory, like du -s. store ∈ datastore (RocksDB), blobstore (blob files). The disk footprint, not the data: see below.
storage_datastore_file_bytes Gauge kind Length of the RocksDB directory’s files by kind: sst (table files), wal (write-ahead logs), other (MANIFEST, OPTIONS, info LOGs).
storage_datastore_table_bytes Gauge kind RocksDB’s own table accounting, summed over column families: live_data (estimated bytes still holding live data), live_sst (table files the current version uses), total_sst (every table file on disk), memtable (unflushed). live_sst - live_data is garbage waiting for compaction; total_sst - live_sst is replaced files an open iterator still pins.
storage_namespace_bytes Gauge namespace_id, column Approximate datastore bytes per namespace this node is a member of. column ∈ state, private_state, delta (summed over the namespace’s contexts), governance (its op log). Same RocksDB estimate as GET /admin-api/usage.
storage_namespace_contexts Gauge namespace_id Contexts in each namespace, so bytes per context is one division.
gc_sweeps Counter — Tombstone GC sweeps completed (every [gc] check_interval).
gc_rows_scanned Counter — State rows those sweeps read. A sweep reads every row, so this rises by the node’s row count each sweep, whether or not anything was deleted.
gc_tombstones_collected Counter — Tombstones collected.
gc_sweep_duration_seconds Histogram — Wall time of one sweep (buckets 10ms–~5.5min). A large share of check_interval means the interval is too short for this store.

A node and its peers converge on a shared root hash per context; group and namespace heartbeats periodically exchange those roots (and DAG heads) so that divergence is detected without a full sync. See Sync & Convergence and Governance for the protocol detail.

  • dag_heads_count histogram sits in the low buckets (1–2 concurrent heads).
  • sync_buffer_drops_total, sync_snapshot_blocked_total, sync_verification_failures_total all stay at 0.
  • governance_pending_queue_depth is flat or trending to 0 (the buffer drains as governance catches up).
  • sync_successes_total / sync_attempts_total is high; the selector mostly picks HashComparison.
  • delta_outcomes_total{outcome="error"} and hc_leaf_drops_total{reason="lookup_error"} are near zero.
  • sync_root_hash_divergence_escalated_total stays at 0 (the ..._detected_total_total counter may still rise under write load; that is expected).
  • merod_build_info is present (scrape pipeline alive, identity confirmed).

Not every convergence signal is a Prometheus series. Two typed state machines surface a node’s sync health at finer granularity than the counters above — both are pushed as events and readable on demand, so a client or operator can tell “actively syncing” from “waiting for a peer” from “stuck behind backoff”.

Each context reports a coarse sync_status phase, published both as the sync_status JSON-RPC response and as a SyncStatus WebSocket event. The phase is recomputed from the context’s SyncState and only re-emitted on a real transition, so the event stream stays quiet between changes (crates/node/src/sync/session.rs, produced from crates/primitives/src/sync_status.rs).

Phase Carries Meaning
Idle — Settled — no sync in flight, nothing pending.
WaitingForPeers — Not yet initialized and nothing actively syncing — typically waiting for a co-member peer to appear. Set explicitly on the benign NoPeersAvailable / PeerNotMaterialized outcomes, which otherwise look idle.
Syncing — A sync attempt is in flight (handshake / delta exchange).
ReceivingSnapshot records_received, percent, eta_secs Receiving an initial-state snapshot. percent / eta_secs are None against a peer too old to advertise the snapshot’s total entity count, degrading to the raw records_received liveness count.
BackingOff retry_in_secs The last attempt failed; the next retry is gated behind the exponential backoff — retry_in_secs is the estimated wait.

Separately, each namespace runs a readiness FSM that governs when the node will participate in the three-phase governance contract. Peers exchange signed beacons advertising how far each has applied; the tier is a pure function of local progress, peer beacons, and elapsed boot grace (crates/node/src/readiness.rs, evaluate_readiness).

Tier Carries Meaning
Bootstrapping — Just subscribed — no peer beacon yet and still within boot_grace, or an empty-DAG joiner with no known target.
LocallyReady — Boot grace elapsed with no peer beacons; the node self-promotes on local state alone.
PeerValidatedReady — Heard a fresh peer beacon and the local applied_through is within applied_through_grace of the peer’s tip. The fully-converged tier.
CatchingUp target_applied_through A peer beacon names a tip the node is still behind; carries the target to catch up to.
Degraded reason Demoted — PendingOps(n) while ops are blocking promotion, or NoRecentPeers when a once-fresh namespace has heard no beacon within ttl_heartbeat.

Readiness config defaults (ReadinessConfig::default): beacon_interval 5 s, ttl_heartbeat 60 s, boot_grace 10 s, applied_through_grace 2 ops. Any local pending op demotes to Degraded immediately — pending ops always win over a tip-fresh peer comparison.

merod initialises tracing with an EnvFilter driven by the RUST_LOG environment variable. When RUST_LOG is unset or empty, the default directive is:

Terminal window
RUST_LOG="merod=info,calimero_=info"

Set it to raise or lower verbosity per module before launching the node:

Terminal window
RUST_LOG="merod=debug,calimero_node=debug,calimero_=info" \
merod --home data/ --node node1 run