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 endpoint
Section titled “The metrics endpoint”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:
curl http://127.0.0.1:2528/metricsThe 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.
Key metric families
Section titled “Key metric families”Sync protocol
Section titled “Sync protocol”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. |
DAG / delta application (node)
Section titled “DAG / delta application (node)”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). |
Governance buffer
Section titled “Governance buffer”| 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. |
Blob transport
Section titled “Blob transport”| 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. |
Network, HTTP, process
Section titled “Network, HTTP, process”| 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. |
Healthy vs divergent
Section titled “Healthy vs divergent”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_counthistogram sits in the low buckets (1–2 concurrent heads).sync_buffer_drops_total,sync_snapshot_blocked_total,sync_verification_failures_totalall stay at 0.governance_pending_queue_depthis flat or trending to 0 (the buffer drains as governance catches up).sync_successes_total / sync_attempts_totalis high; the selector mostly picksHashComparison.delta_outcomes_total{outcome="error"}andhc_leaf_drops_total{reason="lookup_error"}are near zero.sync_root_hash_divergence_escalated_totalstays at 0 (the..._detected_total_totalcounter may still rise under write load; that is expected).merod_build_infois present (scrape pipeline alive, identity confirmed).
- Heartbeats report a root-hash /
scope_rootmismatch against the signed canonical expected; the node’s reconciler kicks in (see Troubleshooting). sync_root_hash_divergence_escalated_totalincrements: a same-DAG / different-root mismatch persisted across a heartbeat period and the log carriesDIVERGENCE DETECTEDplus async::divergence_dumpof the root’s children to diff between peers.sync_protocol_selections_total{protocol="Snapshot"}starts rising — growing snapshot selection means growing state divergence across the cluster.sync_failures_totalclimbs andsync_duration_secondsp99 stretches out.governance_pending_queue_depthgrows monotonically — deltas are buffered because the node has not yet applied the governance that authorises them.dag_heads_countfans out into the higher buckets after a partition.
Sync status & readiness
Section titled “Sync status & readiness”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”.
Per-context sync phase
Section titled “Per-context sync phase”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. |
Per-namespace readiness tier
Section titled “Per-namespace readiness tier”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.
Logging
Section titled “Logging”merod initialises tracing with an EnvFilter driven by the RUST_LOG
environment variable. When RUST_LOG is unset or empty, the default directive
is:
RUST_LOG="merod=info,calimero_=info"Set it to raise or lower verbosity per module before launching the node:
RUST_LOG="merod=debug,calimero_node=debug,calimero_=info" \ merod --home data/ --node node1 runRUST_LOG="warn" merod --home data/ --node node1 run