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}