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