Skip to main content

iota_metrics/
metric_groups.rs

1// Copyright (c) 2026 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4//! Predefined groupings of the node's Prometheus metrics.
5//!
6//! Most groups are filter-based: they key on the module path of the metrics
7//! (preferred over the metric name, because module paths are stable and names
8//! are not) and are rendered into a `METRICS_FILTER`-style string via
9//! [`MetricGroups::to_filter_string`].
10//!
11//! Each filter-based group is set to a [`MetricLevel`], a verbosity threshold.
12//! Individual metrics declare their own level where they are registered,
13//! defaulting to [`MetricLevel::Debug`]. A group's level decides which of its
14//! metrics are **exposed** on the metrics endpoint — a metric is exposed when
15//! the group's level is at least as verbose as the metric's:
16//!
17//! - `off` exposes nothing;
18//! - `warn` (the group default) exposes only `warn`-tagged metrics;
19//! - `info` exposes `warn` and `info` metrics;
20//! - `debug` exposes everything except `trace`-tagged metrics;
21//! - `trace` exposes everything.
22//!
23//! Metrics whose module belongs to no group are covered by the `default`
24//! threshold (`info` unless configured), rendered as the leading catch-all
25//! directive.
26//!
27//! The levels never affect collection: a filter-based group's metrics are
28//! registered and keep collecting regardless of the configured level.
29//!
30//! Note the two defaults differ: an untagged metric is exposed from level
31//! `debug`, while a group defaults to the `warn` threshold, so the default
32//! config exposes only the `warn`-tagged metrics.
33//!
34//! The node applies [`MetricGroups::default()`] when the config omits
35//! `metrics.groups` entirely, so an omitted and an empty section behave the
36//! same.
37//!
38//! The `hw` hardware metrics are registered as a prometheus collector and
39//! so bypass the filtering macros entirely. They cannot be level-filtered
40//! individually: the `hardware` group's level is read once at registration
41//! time — [`MetricLevel::Off`] skips the whole group, any other level
42//! registers it.
43
44pub use prometheus_filtered::MetricLevel;
45use serde::{Deserialize, Serialize};
46
47/// Per-group verbosity levels for the node's predefined Prometheus metric
48/// groups.
49#[derive(Debug, Clone, Deserialize, Serialize)]
50#[serde(rename_all = "kebab-case", default, deny_unknown_fields)]
51pub struct MetricGroups {
52    /// Exposure threshold for metrics whose module belongs to no group.
53    pub default: MetricLevel,
54    /// Consensus and block production.
55    ///
56    /// Modules: `starfish_core`, `iota_core::consensus_adapter`,
57    /// `iota_core::consensus_manager`, `iota_core::consensus_validator`,
58    /// `iota_core::epoch::consensus_store_pruner`.
59    pub consensus: MetricLevel,
60    /// Transaction execution and caching, including the Move bytecode verifier
61    /// and execution-limit meters.
62    ///
63    /// Modules: `iota_core::execution_cache`, `iota_core::global_state_hasher`,
64    /// `iota_core::module_cache_metrics`, `iota_types::metrics`.
65    pub execution: MetricLevel,
66    /// Checkpoint building, certification, and execution.
67    ///
68    /// Modules: `iota_core::checkpoints`.
69    pub checkpoints: MetricLevel,
70    /// Transaction submission and finality.
71    ///
72    /// Modules: `iota_core::quorum_driver`, `iota_core::transaction_driver`,
73    /// `iota_core::transaction_orchestrator`,
74    /// `iota_core::validator_tx_finalizer`.
75    pub transactions: MetricLevel,
76    /// Authority request handling and validation.
77    ///
78    /// Modules: `iota_core::authority` (incl. the authority store and pruner),
79    /// `iota_core::safe_client`, `iota_core::signature_verifier`,
80    /// `iota_core::validator_client_monitor`. Also the `authority_grpc_*`
81    /// transport metrics of the validator gRPC server, matched by name prefix
82    /// because their module (`iota_node::metrics`) is shared with the `epoch`
83    /// group's protocol-version gauges.
84    ///
85    /// The `iota_core::authority` directive is a module-path prefix, so it also
86    /// covers the sibling modules `iota_core::authority_aggregator`,
87    /// `iota_core::authority_client`, and `iota_core::authority_server`.
88    pub authority: MetricLevel,
89    /// Spam/abuse traffic control, including the transaction-deny config
90    /// gauges.
91    ///
92    /// Modules: `iota_core::traffic_controller`,
93    /// `iota_config::node_config_metrics`.
94    pub traffic_control: MetricLevel,
95    /// Peer-to-peer networking and state sync.
96    ///
97    /// Modules: `iota_network::discovery`, `iota_network::randomness`,
98    /// `iota_network::state_sync`.
99    pub network: MetricLevel,
100    /// The anemo P2P transport underneath the `network` group's subsystems.
101    /// Kept separate from `network` because most of these are per-peer
102    /// (connection/RTT/packet-loss gauges) and therefore high-cardinality, so
103    /// they can be silenced independently.
104    ///
105    /// Modules: `iota_metrics::metrics_network`.
106    pub p2p: MetricLevel,
107    /// Persistent storage, including archive writes/reads and state snapshot
108    /// uploads. The authority object store is part of the `authority` group,
109    /// not this one.
110    ///
111    /// Modules: `typed_store`, `iota_storage`,
112    /// `iota_core::db_checkpoint_handler`, `iota_snapshot`.
113    pub storage: MetricLevel,
114    /// API servers and RPC-facing indexes.
115    ///
116    /// Modules: `iota_json_rpc`, `iota_grpc_server`, `iota_graphql_rpc`,
117    /// `iota_core::jsonrpc_index`, `iota_core::subscription_handler`.
118    pub rpc: MetricLevel,
119    /// Epoch reconfiguration and protocol versioning.
120    ///
121    /// Modules: `iota_core::epoch::epoch_metrics`. Also the
122    /// `iota_current/binary/configured_max_protocol_version` gauges, matched by
123    /// name because their module (`iota_node::metrics`) is shared with the
124    /// `authority` group's gRPC transport metrics.
125    pub epoch: MetricLevel,
126    /// Async-runtime and process health: monitored tokio tasks, channels, and
127    /// scopes, thread stalls, invariant violations, and tracing span
128    /// latencies.
129    ///
130    /// Modules: `iota_metrics` (except the `hardware` and `p2p` group
131    /// submodules), `telemetry_subscribers`.
132    pub runtime: MetricLevel,
133    /// Host hardware metrics (CPU / memory / disk). Registered as a collector,
134    /// so individual metrics cannot be level-filtered: `off` skips the whole
135    /// group and every other level registers it, at startup only.
136    ///
137    /// Rendered as an `iota_metrics::hardware_metrics` directive, the module
138    /// where the collector is registered.
139    pub hardware: MetricLevel,
140}
141
142impl Default for MetricGroups {
143    fn default() -> Self {
144        Self {
145            default: MetricLevel::Info,
146            consensus: MetricLevel::Warn,
147            execution: MetricLevel::Warn,
148            checkpoints: MetricLevel::Warn,
149            transactions: MetricLevel::Warn,
150            authority: MetricLevel::Warn,
151            traffic_control: MetricLevel::Warn,
152            network: MetricLevel::Warn,
153            p2p: MetricLevel::Warn,
154            storage: MetricLevel::Warn,
155            rpc: MetricLevel::Warn,
156            epoch: MetricLevel::Warn,
157            runtime: MetricLevel::Warn,
158            hardware: MetricLevel::Warn,
159        }
160    }
161}
162
163impl MetricGroups {
164    /// Returns the filter patterns a group covers — keyed
165    /// by the group's config key. `None` for unknown groups.
166    pub fn modules_for_group(group: &str) -> Option<&'static [&'static str]> {
167        Some(match group {
168            "runtime" => &["iota_metrics", "telemetry_subscribers"],
169            "consensus" => &[
170                "starfish_core",
171                "iota_core::consensus_adapter",
172                "iota_core::consensus_manager",
173                "iota_core::consensus_validator",
174                "iota_core::epoch::consensus_store_pruner",
175            ],
176            "execution" => &[
177                "iota_core::execution_cache",
178                "iota_core::global_state_hasher",
179                "iota_core::module_cache_metrics",
180                "iota_types::metrics",
181            ],
182            "checkpoints" => &["iota_core::checkpoints"],
183            "transactions" => &[
184                "iota_core::quorum_driver",
185                "iota_core::transaction_driver",
186                "iota_core::transaction_orchestrator",
187                "iota_core::validator_tx_finalizer",
188            ],
189            "authority" => &[
190                "iota_core::authority",
191                "iota_core::safe_client",
192                "iota_core::signature_verifier",
193                "iota_core::validator_client_monitor",
194                // Name prefix: the validator gRPC transport metrics live in
195                // `iota_node::metrics` together with the `epoch` group's
196                // protocol-version gauges.
197                "authority_grpc",
198            ],
199            "traffic-control" => &[
200                "iota_core::traffic_controller",
201                "iota_config::node_config_metrics",
202            ],
203            "network" => &[
204                "iota_network::discovery",
205                "iota_network::randomness",
206                "iota_network::state_sync",
207            ],
208            "p2p" => &["iota_metrics::metrics_network"],
209            "storage" => &[
210                "typed_store",
211                "iota_storage",
212                "iota_core::db_checkpoint_handler",
213                "iota_snapshot",
214            ],
215            "rpc" => &[
216                "iota_json_rpc",
217                "iota_grpc_server",
218                "iota_graphql_rpc",
219                "iota_core::jsonrpc_index",
220                "iota_core::subscription_handler",
221            ],
222            "epoch" => &[
223                "iota_core::epoch::epoch_metrics",
224                // Name prefixes: these gauges live in `iota_node::metrics`.
225                "iota_current_protocol_version",
226                "iota_binary_max_protocol_version",
227                "iota_configured_max_protocol_version",
228            ],
229            "hardware" => &["iota_metrics::hardware_metrics"],
230            _ => return None,
231        })
232    }
233
234    /// Maps each group's configured level to the filter patterns it covers.
235    ///
236    /// `runtime` must come first: its `iota_metrics` module prefix also
237    /// matches the `p2p` and `hardware` groups' submodules, and only a
238    /// later directive can override it (last match wins).
239    fn group_modules(&self) -> [(MetricLevel, &'static [&'static str]); 13] {
240        [
241            ("runtime", self.runtime),
242            ("consensus", self.consensus),
243            ("execution", self.execution),
244            ("checkpoints", self.checkpoints),
245            ("transactions", self.transactions),
246            ("authority", self.authority),
247            ("traffic-control", self.traffic_control),
248            ("network", self.network),
249            ("p2p", self.p2p),
250            ("storage", self.storage),
251            ("rpc", self.rpc),
252            ("epoch", self.epoch),
253            ("hardware", self.hardware),
254        ]
255        .map(|(group, level)| {
256            (
257                level,
258                Self::modules_for_group(group).expect("group has modules"),
259            )
260        })
261    }
262
263    /// Renders the levels into a `METRICS_FILTER`-style directive string: the
264    /// `default` threshold as the leading catch-all directive, then one
265    /// directive per group module (including the hardware group's). Later
266    /// directives win, so the group levels override the catch-all for their
267    /// modules.
268    pub fn to_filter_string(&self) -> String {
269        fn token(level: MetricLevel) -> &'static str {
270            match level {
271                MetricLevel::Off => "off",
272                MetricLevel::Warn => "warn",
273                MetricLevel::Info => "info",
274                MetricLevel::Debug => "debug",
275                MetricLevel::Trace => "trace",
276            }
277        }
278        let mut directives = vec![token(self.default).to_owned()];
279        for (level, modules) in self.group_modules() {
280            for module in modules {
281                directives.push(format!("{module}={}", token(level)));
282            }
283        }
284        directives.join(",")
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use prometheus_filtered::Filter;
291
292    use super::{MetricGroups, MetricLevel};
293
294    fn all_trace() -> MetricGroups {
295        MetricGroups {
296            default: MetricLevel::Trace,
297            consensus: MetricLevel::Trace,
298            execution: MetricLevel::Trace,
299            checkpoints: MetricLevel::Trace,
300            transactions: MetricLevel::Trace,
301            authority: MetricLevel::Trace,
302            traffic_control: MetricLevel::Trace,
303            network: MetricLevel::Trace,
304            p2p: MetricLevel::Trace,
305            storage: MetricLevel::Trace,
306            rpc: MetricLevel::Trace,
307            epoch: MetricLevel::Trace,
308            runtime: MetricLevel::Trace,
309            hardware: MetricLevel::Trace,
310        }
311    }
312
313    #[test]
314    fn metric_groups_default_trims_to_warn_tagged() {
315        // The default (all groups `warn`) renders `{module}=warn` for every
316        // group's modules, so only the `warn`-tagged metrics are exposed;
317        // non-grouped modules fall to the `info` catch-all.
318        let filter_string = MetricGroups::default().to_filter_string();
319        assert!(filter_string.starts_with("info,"));
320        assert!(filter_string.contains("starfish_core=warn"));
321        assert!(filter_string.contains("iota_core::execution_cache=warn"));
322        assert!(filter_string.contains("iota_grpc_server=warn"));
323        // No group renders a `debug` directive.
324        assert!(!filter_string.contains("=debug"));
325
326        let filter = Filter::parse(&filter_string);
327        assert!(filter.is_exposed("x", "starfish_core::metrics", MetricLevel::Warn));
328        assert!(!filter.is_exposed("x", "starfish_core::metrics", MetricLevel::Info));
329        assert!(filter.is_exposed("x", "iota_node::some_module", MetricLevel::Info));
330        assert!(!filter.is_exposed("x", "iota_node::some_module", MetricLevel::Debug));
331    }
332
333    #[test]
334    fn metric_groups_renders_level_per_module() {
335        let groups = MetricGroups {
336            execution: MetricLevel::Warn,
337            checkpoints: MetricLevel::Debug,
338            epoch: MetricLevel::Off,
339            ..all_trace()
340        };
341        let filter_string = groups.to_filter_string();
342        assert!(filter_string.starts_with("trace,"));
343        assert!(filter_string.contains("iota_core::execution_cache=warn"));
344        assert!(filter_string.contains("iota_core::checkpoints=debug"));
345        assert!(filter_string.contains("iota_core::epoch::epoch_metrics=off"));
346
347        let filter = Filter::parse(&filter_string);
348        assert!(filter.is_exposed("x", "iota_core::execution_cache", MetricLevel::Warn));
349        assert!(!filter.is_exposed("x", "iota_core::execution_cache", MetricLevel::Info));
350        assert!(filter.is_exposed("x", "iota_core::checkpoints", MetricLevel::Debug));
351        assert!(!filter.is_exposed("x", "iota_core::checkpoints", MetricLevel::Trace));
352        assert!(!filter.is_exposed("x", "iota_core::epoch::epoch_metrics", MetricLevel::Warn));
353        // `trace` groups expose everything — their directives are rendered,
354        // not skipped, so they are not clipped by the catch-all.
355        assert!(filter.is_exposed("x", "iota_core::quorum_driver", MetricLevel::Trace));
356        // Ungrouped modules follow the `default` catch-all (`trace` here).
357        assert!(filter.is_exposed("x", "iota_node::some_module", MetricLevel::Trace));
358    }
359
360    #[test]
361    fn metric_groups_runtime_prefix_is_overridden_by_submodule_groups() {
362        // `runtime` covers the whole `iota_metrics` crate by module prefix,
363        // but the `p2p` and `hardware` submodules belong to their own
364        // groups, whose directives render later and win.
365        let groups = MetricGroups {
366            runtime: MetricLevel::Trace,
367            p2p: MetricLevel::Warn,
368            hardware: MetricLevel::Off,
369            ..all_trace()
370        };
371        let filter = Filter::parse(&groups.to_filter_string());
372        assert!(filter.is_exposed("monitored_tasks", "iota_metrics", MetricLevel::Trace));
373        assert!(filter.is_exposed(
374            "network_peer_rtt",
375            "iota_metrics::metrics_network",
376            MetricLevel::Warn
377        ));
378        assert!(!filter.is_exposed(
379            "network_peer_rtt",
380            "iota_metrics::metrics_network",
381            MetricLevel::Info
382        ));
383        assert!(!filter.is_exposed(
384            "hw_cpu_core_count",
385            "iota_metrics::hardware_metrics",
386            MetricLevel::Warn
387        ));
388    }
389
390    #[test]
391    fn metric_groups_name_patterns_split_shared_module() {
392        // The protocol-version gauges and the gRPC transport metrics share the
393        // `iota_node::metrics` module but belong to different groups, matched
394        // by metric-name prefix.
395        let groups = MetricGroups {
396            epoch: MetricLevel::Off,
397            authority: MetricLevel::Debug,
398            ..all_trace()
399        };
400        let filter = Filter::parse(&groups.to_filter_string());
401        assert!(!filter.is_exposed(
402            "iota_current_protocol_version",
403            "iota_node::metrics",
404            MetricLevel::Warn
405        ));
406        assert!(filter.is_exposed(
407            "authority_grpc_requests",
408            "iota_node::metrics",
409            MetricLevel::Debug
410        ));
411    }
412
413    #[test]
414    fn metric_groups_renders_hardware_directive() {
415        use crate::hardware_metrics::hardware_metrics_enabled;
416
417        let groups = MetricGroups {
418            hardware: MetricLevel::Off,
419            ..all_trace()
420        };
421        // `hardware` is gated at registration, via the rendered directive.
422        let filter_string = groups.to_filter_string();
423        assert!(filter_string.contains("iota_metrics::hardware_metrics=off"));
424        assert!(!hardware_metrics_enabled(&Filter::parse(&filter_string)));
425        // A `METRICS_FILTER` directive is appended after the config's and
426        // overrides it.
427        let overridden = Filter::parse(&format!(
428            "{filter_string},iota_metrics::hardware_metrics=warn"
429        ));
430        assert!(hardware_metrics_enabled(&overridden));
431    }
432
433    #[test]
434    fn modules_for_group_covers_every_group() {
435        // Every group resolves to a non-empty module list; the rendered
436        // filter contains exactly those modules.
437        let filter = MetricGroups::default().to_filter_string();
438        for group in [
439            "consensus",
440            "execution",
441            "checkpoints",
442            "transactions",
443            "authority",
444            "traffic-control",
445            "network",
446            "p2p",
447            "storage",
448            "rpc",
449            "epoch",
450            "runtime",
451            "hardware",
452        ] {
453            let modules = MetricGroups::modules_for_group(group)
454                .unwrap_or_else(|| panic!("group {group} has no modules"));
455            assert!(!modules.is_empty());
456            for module in modules {
457                assert!(filter.contains(&format!("{module}=warn")));
458            }
459        }
460        // Unknown names resolve to nothing.
461        assert_eq!(MetricGroups::modules_for_group("bogus"), None);
462    }
463
464    #[test]
465    fn metric_groups_config_parsing() {
466        // Omitted groups default to `warn`; the explicitly-set group keeps its
467        // value.
468        let groups: MetricGroups = serde_yaml::from_str("traffic-control: off").unwrap();
469        assert_eq!(groups.default, MetricLevel::Info);
470        assert_eq!(groups.consensus, MetricLevel::Warn);
471        assert_eq!(groups.traffic_control, MetricLevel::Off);
472        assert_eq!(groups.hardware, MetricLevel::Warn);
473        let filter = groups.to_filter_string();
474        assert!(filter.contains("iota_core::traffic_controller=off"));
475        assert!(filter.contains("starfish_core=warn"));
476
477        // A typo'd group name must fail config load instead of silently
478        // leaving the intended group at its default.
479        assert!(serde_yaml::from_str::<MetricGroups>("traffic_control: off").is_err());
480        assert!(serde_yaml::from_str::<MetricGroups>("bogus: warn").is_err());
481    }
482}