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 other group form the `default` group,
24//! covered by the `default` threshold (`info` unless configured), rendered
25//! as the leading `default=LEVEL` 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 `hardware` metrics are grouped together as one collector and
39//! registered with `warn` level, so the whole group shares a single level.
40
41use std::collections::BTreeMap;
42
43pub use prometheus_filtered::MetricLevel;
44use serde::{Deserialize, Serialize};
45
46/// Per-group verbosity levels for the node's predefined Prometheus metric
47/// groups.
48#[derive(Debug, Clone, Deserialize, Serialize)]
49#[serde(rename_all = "kebab-case", default, deny_unknown_fields)]
50pub struct MetricGroups {
51    /// Exposure threshold for metrics whose module belongs to no group.
52    pub default: MetricLevel,
53    /// Consensus and block production.
54    ///
55    /// Modules: `starfish_core`, `iota_core::consensus_adapter`,
56    /// `iota_core::consensus_manager`, `iota_core::consensus_validator`,
57    /// `iota_core::epoch::consensus_store_pruner`.
58    pub consensus: MetricLevel,
59    /// Transaction execution and caching, including the Move bytecode verifier
60    /// and execution-limit meters.
61    ///
62    /// Modules: `iota_core::execution_cache`, `iota_core::global_state_hasher`,
63    /// `iota_core::module_cache_metrics`, `iota_types::metrics`.
64    pub execution: MetricLevel,
65    /// Checkpoint building, certification, and execution.
66    ///
67    /// Modules: `iota_core::checkpoints`.
68    pub checkpoints: MetricLevel,
69    /// Transaction submission and finality.
70    ///
71    /// Modules: `iota_core::quorum_driver`, `iota_core::transaction_driver`,
72    /// `iota_core::transaction_orchestrator`,
73    /// `iota_core::validator_tx_finalizer`.
74    pub transactions: MetricLevel,
75    /// Authority request handling and validation.
76    ///
77    /// Modules: `iota_core::authority` (incl. the authority store and pruner),
78    /// `iota_core::safe_client`, `iota_core::signature_verifier`,
79    /// `iota_core::validator_client_monitor`. Also the `authority_grpc_*`
80    /// transport and connection metrics of the validator gRPC server. They
81    /// match by name prefix, because their modules are shared with other
82    /// groups: `iota_node::metrics` with the `epoch` group and
83    /// `iota_http::metrics` with the `rpc` group.
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`, `iota_snapshot`.
112    pub storage: MetricLevel,
113    /// API servers and RPC-facing indexes.
114    ///
115    /// Modules: `iota_json_rpc`, `iota_grpc_server`, `iota_graphql_rpc`,
116    /// `iota_http` (the connection metrics of the JSON-RPC listener; those of
117    /// the validator listener are `authority_grpc_*`, so they belong to the
118    /// `authority` group), `iota_core::jsonrpc_index`,
119    /// `iota_core::subscription_handler`.
120    pub rpc: MetricLevel,
121    /// Epoch reconfiguration and protocol versioning.
122    ///
123    /// Modules: `iota_core::epoch::epoch_metrics`. Also the
124    /// `iota_current/binary/configured_max_protocol_version` gauges, matched by
125    /// name because their module (`iota_node::metrics`) is shared with the
126    /// `authority` group's gRPC transport metrics.
127    pub epoch: MetricLevel,
128    /// Async-runtime and process health: monitored tokio tasks, channels, and
129    /// scopes, per-runtime tokio scheduler metrics (`tokio_runtime_*`), thread
130    /// stalls, invariant violations, and tracing span latencies.
131    ///
132    /// Modules: `iota_metrics` (except the `hardware` and `p2p` group
133    /// submodules), `telemetry_subscribers`.
134    pub runtime: MetricLevel,
135    /// Host hardware metrics (CPU / memory / disk). Individual hardware metrics
136    /// cannot be given their own level.
137    ///
138    /// Rendered as an `iota_metrics::hardware_metrics` directive, the module
139    /// where the collector is registered.
140    pub hardware: MetricLevel,
141    /// Free-form overrides for module paths or metric names, including ones
142    /// already covered by a named group: the most specific matching pattern
143    /// decides each metric (a metric-name match wins over a module match),
144    /// so an override can raise or lower a single module or metric within a
145    /// group. A `default` key is the same pattern as the `default` field and
146    /// replaces its level; likewise a group-name key expands to the group's
147    /// patterns and replaces the group field's level, as the same directive
148    /// would in `METRICS_FILTER` or the admin endpoint.
149    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
150    pub overrides: BTreeMap<String, MetricLevel>,
151}
152
153impl Default for MetricGroups {
154    fn default() -> Self {
155        Self {
156            default: MetricLevel::Info,
157            consensus: MetricLevel::Warn,
158            execution: MetricLevel::Warn,
159            checkpoints: MetricLevel::Warn,
160            transactions: MetricLevel::Warn,
161            authority: MetricLevel::Warn,
162            traffic_control: MetricLevel::Warn,
163            network: MetricLevel::Warn,
164            p2p: MetricLevel::Warn,
165            storage: MetricLevel::Warn,
166            rpc: MetricLevel::Warn,
167            epoch: MetricLevel::Warn,
168            runtime: MetricLevel::Warn,
169            hardware: MetricLevel::Warn,
170            overrides: BTreeMap::new(),
171        }
172    }
173}
174
175impl MetricGroups {
176    /// Returns the filter patterns a group covers — keyed
177    /// by the group's config key. `None` for unknown groups.
178    fn modules_for_group(group: &str) -> Option<&'static [&'static str]> {
179        Some(match group {
180            "runtime" => &["iota_metrics", "telemetry_subscribers"],
181            "consensus" => &[
182                "starfish_core",
183                "iota_core::consensus_adapter",
184                "iota_core::consensus_manager",
185                "iota_core::consensus_validator",
186                "iota_core::epoch::consensus_store_pruner",
187            ],
188            "execution" => &[
189                "iota_core::execution_cache",
190                "iota_core::global_state_hasher",
191                "iota_core::module_cache_metrics",
192                "iota_types::metrics",
193            ],
194            "checkpoints" => &["iota_core::checkpoints"],
195            "transactions" => &[
196                "iota_core::quorum_driver",
197                "iota_core::transaction_driver",
198                "iota_core::transaction_orchestrator",
199                "iota_core::validator_tx_finalizer",
200            ],
201            "authority" => &[
202                "iota_core::authority",
203                "iota_core::safe_client",
204                "iota_core::signature_verifier",
205                "iota_core::validator_client_monitor",
206                // Name prefix: the validator gRPC transport metrics live in
207                // `iota_node::metrics` together with the `epoch` group's
208                // protocol-version gauges.
209                "authority_grpc",
210            ],
211            "traffic-control" => &[
212                "iota_core::traffic_controller",
213                "iota_config::node_config_metrics",
214            ],
215            "network" => &[
216                "iota_network::discovery",
217                "iota_network::randomness",
218                "iota_network::state_sync",
219            ],
220            "p2p" => &["iota_metrics::metrics_network"],
221            "storage" => &["typed_store", "iota_storage", "iota_snapshot"],
222            "rpc" => &[
223                "iota_json_rpc",
224                "iota_grpc_server",
225                "iota_graphql_rpc",
226                "iota_http",
227                "iota_core::jsonrpc_index",
228                "iota_core::subscription_handler",
229            ],
230            "epoch" => &[
231                "iota_core::epoch::epoch_metrics",
232                // Name prefixes: these gauges live in `iota_node::metrics`.
233                "iota_current_protocol_version",
234                "iota_binary_max_protocol_version",
235                "iota_configured_max_protocol_version",
236            ],
237            "hardware" => &["iota_metrics::hardware_metrics"],
238            _ => return None,
239        })
240    }
241
242    /// The predefined groups paired with their configured levels.
243    fn group_levels(&self) -> [(&'static str, MetricLevel); 13] {
244        [
245            ("runtime", self.runtime),
246            ("consensus", self.consensus),
247            ("execution", self.execution),
248            ("checkpoints", self.checkpoints),
249            ("transactions", self.transactions),
250            ("authority", self.authority),
251            ("traffic-control", self.traffic_control),
252            ("network", self.network),
253            ("p2p", self.p2p),
254            ("storage", self.storage),
255            ("rpc", self.rpc),
256            ("epoch", self.epoch),
257            ("hardware", self.hardware),
258        ]
259    }
260
261    /// Each group's configured level paired with the module paths it covers.
262    fn group_modules(&self) -> [(MetricLevel, &'static [&'static str]); 13] {
263        self.group_levels().map(|(group, level)| {
264            (
265                level,
266                Self::modules_for_group(group).expect("group has modules"),
267            )
268        })
269    }
270
271    /// Renders the levels into a `METRICS_FILTER`-style directive string.
272    fn to_filter_string(&self) -> String {
273        let mut directives = vec![format!("default={}", self.default.as_str())];
274        for (level, modules) in self.group_modules() {
275            for module in modules {
276                directives.push(format!("{module}={}", level.as_str()));
277            }
278        }
279        for directive in self.override_directives() {
280            // Group-name keys expand exactly like env var and runtime
281            // directives; rendered after the group directives, the expansion
282            // replaces the group field's level.
283            directives.extend(
284                Self::expand_directive(&directive)
285                    .expect("override levels are typed, so the rendered directive is valid"),
286            );
287        }
288        directives.join(",")
289    }
290
291    /// Renders the levels into a group-form directive string.
292    /// Keyed by group name rather than expanded
293    /// to module paths, so the admin endpoint can echo the config compactly.
294    fn to_display_string(&self) -> String {
295        let mut directives = vec![format!("default={}", self.default.as_str())];
296        for (group, level) in self.group_levels() {
297            directives.push(format!("{group}={}", level.as_str()));
298        }
299        directives.extend(self.override_directives());
300        directives.join(",")
301    }
302
303    /// Expands group names in a `pattern=LEVEL` directive string into the
304    /// groups' filter patterns; other directives pass through unchanged.
305    /// Any invalid directive rejects the whole string, with every offending
306    /// directive reported.
307    pub(crate) fn expand_directives(filter: &str) -> Result<String, String> {
308        let (directives, errors) = Self::expand_startup_directives(filter);
309        if errors.is_empty() {
310            Ok(directives)
311        } else {
312            Err(errors.join("; "))
313        }
314    }
315
316    /// Like [`Self::expand_directives`], but for startup use: an invalid
317    /// directive is dropped instead of rejecting the whole string, its error
318    /// message returned alongside the expanded directives.
319    fn expand_startup_directives(filter: &str) -> (String, Vec<String>) {
320        let mut directives = Vec::new();
321        let mut errors = Vec::new();
322        for part in prometheus_filtered::directive_parts(filter) {
323            match Self::expand_directive(part) {
324                Ok(expanded) => directives.extend(expanded),
325                Err(err) => errors.push(err),
326            }
327        }
328        (directives.join(","), errors)
329    }
330
331    /// Builds the startup metrics filter: these group levels with the `env`
332    /// directives merged over them. An env bare level replaces the group
333    /// directives entirely instead of merging, so `METRICS_FILTER=trace`
334    /// exposes everything whatever the groups configure.
335    /// Invalid env directives are dropped.
336    ///
337    /// Matching uses the expanded module directives; the admin endpoint
338    /// echoes the group-form strings, so each source keeps both.
339    pub fn startup_filter(&self, env: Option<&str>) -> (prometheus_filtered::Filter, Vec<String>) {
340        let directives = self.to_filter_string();
341        let display = self.to_display_string();
342        let config = prometheus_filtered::FilterSource::with_display(&directives, &display);
343        match env {
344            Some(env) => {
345                let (expanded, errors) = Self::expand_startup_directives(env);
346                let filter = prometheus_filtered::Filter::from_sources(
347                    config,
348                    Some(prometheus_filtered::FilterSource::with_display(
349                        &expanded, env,
350                    )),
351                );
352                (filter, errors)
353            }
354            None => (
355                prometheus_filtered::Filter::from_sources(config, None),
356                Vec::new(),
357            ),
358        }
359    }
360
361    fn expand_directive(part: &str) -> Result<Vec<String>, String> {
362        let (pattern, level) = prometheus_filtered::split_directive(part)?;
363        Ok(match Self::modules_for_group(pattern) {
364            Some(modules) => modules
365                .iter()
366                .map(|module| format!("{module}={}", level.as_str()))
367                .collect(),
368            // A raw module path, metric-name prefix, or bare global level passes through unchanged.
369            None => vec![part.to_owned()],
370        })
371    }
372
373    /// Renders the free-form overrides as `pattern=LEVEL` directives.
374    fn override_directives(&self) -> impl Iterator<Item = String> + '_ {
375        self.overrides
376            .iter()
377            .map(|(pattern, level)| format!("{pattern}={}", level.as_str()))
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use std::collections::BTreeMap;
384
385    use prometheus_filtered::Filter;
386
387    use super::{MetricGroups, MetricLevel};
388
389    fn all_trace() -> MetricGroups {
390        MetricGroups {
391            default: MetricLevel::Trace,
392            consensus: MetricLevel::Trace,
393            execution: MetricLevel::Trace,
394            checkpoints: MetricLevel::Trace,
395            transactions: MetricLevel::Trace,
396            authority: MetricLevel::Trace,
397            traffic_control: MetricLevel::Trace,
398            network: MetricLevel::Trace,
399            p2p: MetricLevel::Trace,
400            storage: MetricLevel::Trace,
401            rpc: MetricLevel::Trace,
402            epoch: MetricLevel::Trace,
403            runtime: MetricLevel::Trace,
404            hardware: MetricLevel::Trace,
405            overrides: BTreeMap::new(),
406        }
407    }
408
409    #[test]
410    fn metric_groups_default_trims_to_warn_tagged() {
411        // The default (all groups `warn`) renders `{module}=warn` for every
412        // group's modules, so only the `warn`-tagged metrics are exposed;
413        // non-grouped modules fall to the `default` group's `info`.
414        let filter_string = MetricGroups::default().to_filter_string();
415        assert!(filter_string.starts_with("default=info,"));
416        assert!(filter_string.contains("starfish_core=warn"));
417        assert!(filter_string.contains("iota_core::execution_cache=warn"));
418        assert!(filter_string.contains("iota_grpc_server=warn"));
419        // No group renders a `debug` directive.
420        assert!(!filter_string.contains("=debug"));
421
422        let filter = Filter::parse(&filter_string);
423        assert!(filter.is_exposed("x", "starfish_core::metrics", MetricLevel::Warn));
424        assert!(!filter.is_exposed("x", "starfish_core::metrics", MetricLevel::Info));
425        assert!(filter.is_exposed("x", "iota_node::some_module", MetricLevel::Info));
426        assert!(!filter.is_exposed("x", "iota_node::some_module", MetricLevel::Debug));
427
428        // A bare env level replaces all group directives instead of merging
429        // over them, so `METRICS_FILTER=trace` (which the benchmark tooling
430        // exports to read gated metrics) exposes everything.
431        let (filter, errors) = MetricGroups::default().startup_filter(Some("trace"));
432        assert!(errors.is_empty());
433        assert!(filter.is_exposed("x", "iota_core::execution_cache", MetricLevel::Trace));
434        assert!(filter.is_exposed("x", "iota_node::some_module", MetricLevel::Trace));
435        assert_eq!(filter.startup_filter_string(), "trace");
436    }
437
438    #[test]
439    fn to_display_string_keys_by_group_name() {
440        // The display form keeps group names rather than expanding to modules.
441        let display = MetricGroups {
442            consensus: MetricLevel::Off,
443            storage: MetricLevel::Trace,
444            ..MetricGroups::default()
445        }
446        .to_display_string();
447        assert!(display.starts_with("default=info,"));
448        assert!(display.contains("consensus=off"));
449        assert!(display.contains("storage=trace"));
450        assert!(display.contains("hardware=warn"));
451        // No module paths leak into the display form.
452        assert!(!display.contains("::"));
453        assert!(!display.contains("starfish_core"));
454    }
455
456    #[test]
457    fn metric_groups_renders_level_per_module() {
458        let groups = MetricGroups {
459            execution: MetricLevel::Warn,
460            checkpoints: MetricLevel::Debug,
461            epoch: MetricLevel::Off,
462            ..all_trace()
463        };
464        let filter_string = groups.to_filter_string();
465        assert!(filter_string.starts_with("default=trace,"));
466        assert!(filter_string.contains("iota_core::execution_cache=warn"));
467        assert!(filter_string.contains("iota_core::checkpoints=debug"));
468        assert!(filter_string.contains("iota_core::epoch::epoch_metrics=off"));
469
470        let filter = Filter::parse(&filter_string);
471        assert!(filter.is_exposed("x", "iota_core::execution_cache", MetricLevel::Warn));
472        assert!(!filter.is_exposed("x", "iota_core::execution_cache", MetricLevel::Info));
473        assert!(filter.is_exposed("x", "iota_core::checkpoints", MetricLevel::Debug));
474        assert!(!filter.is_exposed("x", "iota_core::checkpoints", MetricLevel::Trace));
475        assert!(!filter.is_exposed("x", "iota_core::epoch::epoch_metrics", MetricLevel::Warn));
476        // `trace` groups expose everything — their directives are rendered,
477        // not skipped, so they are not clipped by the `default` level.
478        assert!(filter.is_exposed("x", "iota_core::quorum_driver", MetricLevel::Trace));
479        // Ungrouped modules follow the `default` level (`trace` here).
480        assert!(filter.is_exposed("x", "iota_node::some_module", MetricLevel::Trace));
481    }
482
483    #[test]
484    fn metric_groups_runtime_prefix_is_overridden_by_submodule_groups() {
485        // `runtime` covers the whole `iota_metrics` crate by module prefix,
486        // but the `p2p` and `hardware` submodules belong to their own
487        // groups, whose more specific patterns win.
488        let groups = MetricGroups {
489            runtime: MetricLevel::Trace,
490            p2p: MetricLevel::Warn,
491            hardware: MetricLevel::Off,
492            ..all_trace()
493        };
494        let filter = Filter::parse(&groups.to_filter_string());
495        assert!(filter.is_exposed("monitored_tasks", "iota_metrics", MetricLevel::Trace));
496        assert!(filter.is_exposed(
497            "network_peer_rtt",
498            "iota_metrics::metrics_network",
499            MetricLevel::Warn
500        ));
501        assert!(!filter.is_exposed(
502            "network_peer_rtt",
503            "iota_metrics::metrics_network",
504            MetricLevel::Info
505        ));
506        assert!(!filter.is_exposed(
507            "hw_cpu_core_count",
508            "iota_metrics::hardware_metrics",
509            MetricLevel::Warn
510        ));
511    }
512
513    #[test]
514    fn metric_groups_name_patterns_split_shared_module() {
515        // The protocol-version gauges and the gRPC transport metrics share the
516        // `iota_node::metrics` module but belong to different groups, matched
517        // by metric-name prefix.
518        let groups = MetricGroups {
519            epoch: MetricLevel::Off,
520            authority: MetricLevel::Debug,
521            ..all_trace()
522        };
523        let filter = Filter::parse(&groups.to_filter_string());
524        assert!(!filter.is_exposed(
525            "iota_current_protocol_version",
526            "iota_node::metrics",
527            MetricLevel::Warn
528        ));
529        assert!(filter.is_exposed(
530            "authority_grpc_requests",
531            "iota_node::metrics",
532            MetricLevel::Debug
533        ));
534    }
535
536    #[test]
537    fn modules_for_group_covers_every_group() {
538        // Every group resolves to a non-empty module list; the rendered
539        // filter contains exactly those modules.
540        let filter = MetricGroups::default().to_filter_string();
541        for group in [
542            "consensus",
543            "execution",
544            "checkpoints",
545            "transactions",
546            "authority",
547            "traffic-control",
548            "network",
549            "p2p",
550            "storage",
551            "rpc",
552            "epoch",
553            "runtime",
554            "hardware",
555        ] {
556            let modules = MetricGroups::modules_for_group(group)
557                .unwrap_or_else(|| panic!("group {group} has no modules"));
558            assert!(!modules.is_empty());
559            for module in modules {
560                assert!(filter.contains(&format!("{module}=warn")));
561            }
562        }
563        // Unknown names resolve to nothing.
564        assert_eq!(MetricGroups::modules_for_group("bogus"), None);
565    }
566
567    #[test]
568    fn expand_directives_expands_groups_and_passes_raw_patterns() {
569        assert_eq!(
570            MetricGroups::expand_directives("checkpoints=off,epoch=debug").unwrap(),
571            "iota_core::checkpoints=off,iota_core::epoch::epoch_metrics=debug,\
572             iota_current_protocol_version=debug,iota_binary_max_protocol_version=debug,\
573             iota_configured_max_protocol_version=debug"
574        );
575        // Raw module paths, metric-name prefixes, and bare global levels are
576        // kept verbatim; level validity is checked up front.
577        assert_eq!(
578            MetricGroups::expand_directives("typed_store=warn, uptime=off ,trace").unwrap(),
579            "typed_store=warn,uptime=off,trace"
580        );
581        assert_eq!(MetricGroups::expand_directives("").unwrap(), "");
582        // The reserved `default` pattern passes through unexpanded; it sets
583        // the `default` group's level and leaves the group directives in
584        // place.
585        assert_eq!(
586            MetricGroups::expand_directives("default=info,traffic-control=off").unwrap(),
587            "default=info,iota_core::traffic_controller=off,\
588             iota_config::node_config_metrics=off"
589        );
590        // The single-collector hardware group expands like any other.
591        assert_eq!(
592            MetricGroups::expand_directives("hardware=off").unwrap(),
593            "iota_metrics::hardware_metrics=off"
594        );
595        assert_eq!(
596            MetricGroups::expand_directives("iota_metrics=off,runtime=warn").unwrap(),
597            "iota_metrics=off,iota_metrics=warn,telemetry_subscribers=warn"
598        );
599    }
600
601    #[test]
602    fn runtime_group_override_keeps_other_groups_untouched() {
603        use prometheus_filtered::FilterSource;
604
605        // The `runtime` group's `iota_metrics` module prefix also covers the
606        // `p2p` and `hardware` groups' submodules; overriding `runtime` must
607        // not change those groups' exposure, matching the group definition.
608        let groups = MetricGroups {
609            hardware: MetricLevel::Off,
610            ..MetricGroups::default()
611        };
612        let filter = prometheus_filtered::Filter::from_sources(
613            FilterSource::with_display(&groups.to_filter_string(), &groups.to_display_string()),
614            None,
615        );
616        let expanded = MetricGroups::expand_directives("runtime=trace").unwrap();
617        filter
618            .set_runtime_filter(FilterSource::with_display(&expanded, "runtime=trace"))
619            .unwrap();
620
621        // The runtime group's own modules are raised ...
622        assert!(filter.is_exposed("x", "iota_metrics::monitored_mpsc", MetricLevel::Trace));
623        // ... while hardware stays off and p2p keeps its configured `warn`.
624        assert!(!filter.is_exposed(
625            "hw_metrics",
626            "iota_metrics::hardware_metrics",
627            MetricLevel::Warn
628        ));
629        assert!(!filter.is_exposed("x", "iota_metrics::metrics_network", MetricLevel::Info));
630        // The reported filter reflects that: only the runtime entry changed.
631        let display = filter.filter_string();
632        assert!(display.contains("runtime=trace"), "{display}");
633        assert!(display.contains("hardware=off"), "{display}");
634        assert!(display.contains("p2p=warn"), "{display}");
635        assert!(!display.contains("runtime=warn"), "{display}");
636    }
637
638    #[test]
639    fn expand_startup_directives_drops_bad_directives() {
640        // An invalid directive is dropped and reported; the rest still expand.
641        let (expanded, errors) =
642            MetricGroups::expand_startup_directives("consensus=bogus,traffic-control=off");
643        assert_eq!(
644            expanded,
645            "iota_core::traffic_controller=off,iota_config::node_config_metrics=off"
646        );
647        assert_eq!(errors.len(), 1);
648        assert!(
649            errors[0].contains("consensus=bogus"),
650            "unexpected error: {}",
651            errors[0]
652        );
653    }
654
655    #[test]
656    fn expand_directives_rejects_invalid_input() {
657        // An invalid level fails the whole string, citing every offending
658        // directive as the caller wrote it — not its expansion.
659        let err =
660            MetricGroups::expand_directives("consensus=bogus,storage=warn,epoch=nah").unwrap_err();
661        assert!(err.contains("consensus=bogus"), "unexpected error: {err}");
662        assert!(err.contains("epoch=nah"), "unexpected error: {err}");
663    }
664
665    #[test]
666    fn metric_groups_config_parsing() {
667        // Omitted groups default to `warn`; the explicitly-set group keeps its
668        // value.
669        let groups: MetricGroups = serde_yaml::from_str("traffic-control: off").unwrap();
670        assert_eq!(groups.default, MetricLevel::Info);
671        assert_eq!(groups.consensus, MetricLevel::Warn);
672        assert_eq!(groups.traffic_control, MetricLevel::Off);
673        assert_eq!(groups.hardware, MetricLevel::Warn);
674        let filter = groups.to_filter_string();
675        assert!(filter.contains("iota_core::traffic_controller=off"));
676        assert!(filter.contains("starfish_core=warn"));
677
678        // A typo'd group name must fail config load instead of silently
679        // leaving the intended group at its default.
680        assert!(serde_yaml::from_str::<MetricGroups>("traffic_control: off").is_err());
681        assert!(serde_yaml::from_str::<MetricGroups>("bogus: warn").is_err());
682    }
683
684    #[test]
685    fn metric_groups_config_allows_free_overrides() {
686        // Module- and metric-level directives that no named group covers go in
687        // the `overrides` map, keeping group-name typo protection intact.
688        let groups: MetricGroups = serde_yaml::from_str(
689            "consensus: off\n\
690             overrides:\n  \"iota_core::authority::foo\": trace\n  bespoke_metric: off\n  \
691             certs_total: trace\n  network: trace",
692        )
693        .unwrap();
694        assert_eq!(groups.consensus, MetricLevel::Off);
695
696        let filter_string = groups.to_filter_string();
697        assert!(filter_string.contains("iota_core::authority::foo=trace"));
698        assert!(filter_string.contains("bespoke_metric=off"));
699        // A group-name key expands to the group's module patterns, replacing
700        // the group field's level (`warn` here).
701        assert!(filter_string.contains("iota_network::discovery=trace"));
702
703        let filter = Filter::parse(&filter_string);
704        // The longer override pattern wins over the `authority` group directive.
705        assert!(filter.is_exposed("x", "iota_core::authority::foo", MetricLevel::Trace));
706        assert!(!filter.is_exposed("bespoke_metric_total", "somewhere", MetricLevel::Warn));
707        // A metric-name override wins over its module's group directive
708        // (`iota_core::execution_cache=warn` here) even though the name is
709        // the shorter pattern.
710        assert!(filter.is_exposed(
711            "certs_total",
712            "iota_core::execution_cache",
713            MetricLevel::Trace
714        ));
715        // Other metrics in the module keep the group's level.
716        assert!(!filter.is_exposed("other", "iota_core::execution_cache", MetricLevel::Info));
717        // The expanded group-name override applies to the group's modules ...
718        assert!(filter.is_exposed("x", "iota_network::discovery", MetricLevel::Trace));
719        // ... and other groups keep their configured level.
720        assert!(!filter.is_exposed("x", "iota_storage::http_key_value_store", MetricLevel::Info));
721    }
722}