Skip to main content

hermod/dispatcher/
config.rs

1//! Trace configuration types and YAML parsing
2//!
3//! Mirrors Haskell `TraceConfig`, `ConfigOption`, `BackendConfig`, `FormatLogging`
4//! from `Cardano.Logging.Types` and `ConfigurationParser`.
5
6use crate::dispatcher::traits::SeverityF;
7use crate::protocol::types::{DetailLevel, Severity};
8use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::path::Path;
12
13/// Logging format for the Stdout backend
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
15pub enum FormatLogging {
16    /// Human-readable with ANSI colour codes
17    HumanFormatColoured,
18    /// Human-readable without colour codes
19    HumanFormatUncoloured,
20    /// Machine-readable JSON
21    MachineFormat,
22}
23
24/// Which backend should receive a trace message
25#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
26pub enum BackendConfig {
27    /// Forward to hermod-tracer via the trace-forward protocol
28    Forwarder,
29    /// Write to standard output
30    Stdout(FormatLogging),
31    /// Push to EKG / Prometheus
32    EkgBackend,
33    /// Send to a datapoint backend (stub)
34    DatapointBackend,
35}
36
37/// Configuration option for a single namespace entry
38///
39/// Mirrors Haskell `ConfigOption`.
40#[derive(Debug, Clone, PartialEq)]
41pub enum ConfigOption {
42    /// Severity filter (None = Silence)
43    Severity(SeverityF),
44    /// Detail level
45    Detail(DetailLevel),
46    /// List of backends to route to
47    Backends(Vec<BackendConfig>),
48    /// Rate limiter: maximum messages per second
49    Limiter(f64),
50}
51
52/// Forwarder connection options
53#[derive(Debug, Clone, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct ForwarderOptions {
56    /// Path to the Unix socket
57    pub socket_path: Option<String>,
58    /// Outbound queue size
59    pub queue_size: Option<u32>,
60    /// Maximum reconnection delay in seconds
61    pub max_reconnect_delay: Option<u32>,
62}
63
64/// Top-level trace configuration
65///
66/// Mirrors Haskell `TraceConfig`.
67#[derive(Debug, Clone, Default)]
68pub struct TraceConfig {
69    /// Namespace-keyed configuration options (longest-prefix-match lookup)
70    pub options: BTreeMap<Vec<String>, Vec<ConfigOption>>,
71    /// Optional forwarder connection settings
72    pub forwarder: Option<ForwarderOptions>,
73    /// Optional human-readable node name
74    pub node_name: Option<String>,
75}
76
77impl TraceConfig {
78    /// Look up a config option using longest-prefix-match semantics.
79    ///
80    /// Mirrors Haskell `getOption sel config ns`.
81    pub fn get_option<F, T>(&self, ns: &[String], selector: F) -> Option<T>
82    where
83        F: Fn(&ConfigOption) -> Option<T>,
84    {
85        // Try the exact key, then progressively shorter prefixes down to `[]`
86        let mut key = ns.to_vec();
87        loop {
88            if let Some(opts) = self.options.get(&key) {
89                if let Some(v) = opts.iter().find_map(&selector) {
90                    return Some(v);
91                }
92            }
93            if key.is_empty() {
94                return None;
95            }
96            key.pop();
97        }
98    }
99
100    /// Get the severity filter for a namespace
101    pub fn severity_for(&self, ns: &[String]) -> SeverityF {
102        self.get_option(ns, |o| {
103            if let ConfigOption::Severity(s) = o {
104                Some(*s)
105            } else {
106                None
107            }
108        })
109        .unwrap_or(SeverityF(Some(Severity::Warning)))
110    }
111
112    /// Get the detail level for a namespace
113    pub fn detail_for(&self, ns: &[String]) -> DetailLevel {
114        self.get_option(ns, |o| {
115            if let ConfigOption::Detail(d) = o {
116                Some(*d)
117            } else {
118                None
119            }
120        })
121        .unwrap_or(DetailLevel::DNormal)
122    }
123
124    /// Get the backend list for a namespace
125    pub fn backends_for(&self, ns: &[String]) -> Vec<BackendConfig> {
126        self.get_option(ns, |o| {
127            if let ConfigOption::Backends(b) = o {
128                Some(b.clone())
129            } else {
130                None
131            }
132        })
133        .unwrap_or_else(|| {
134            vec![
135                BackendConfig::Stdout(FormatLogging::MachineFormat),
136                BackendConfig::EkgBackend,
137                BackendConfig::Forwarder,
138            ]
139        })
140    }
141
142    /// Get the rate limiter max-frequency for a namespace, if configured
143    pub fn limiter_for(&self, ns: &[String]) -> Option<f64> {
144        self.get_option(ns, |o| {
145            if let ConfigOption::Limiter(f) = o {
146                Some(*f)
147            } else {
148                None
149            }
150        })
151    }
152
153    /// Build a [`crate::forwarder::ForwarderConfig`] from this `TraceConfig`.
154    ///
155    /// Returns `None` if `self.forwarder` is not set (no forwarder configured).
156    /// The `node_name` field is propagated automatically so the forwarder
157    /// advertises the correct name via the `NodeInfo` DataPoint.
158    pub fn forwarder_config(&self) -> Option<crate::forwarder::ForwarderConfig> {
159        let opts = self.forwarder.as_ref()?;
160        let mut cfg = crate::forwarder::ForwarderConfig::default();
161        if let Some(path) = &opts.socket_path {
162            cfg.address = crate::forwarder::ForwarderAddress::Unix(std::path::PathBuf::from(path));
163        }
164        if let Some(qs) = opts.queue_size {
165            cfg.queue_size = qs as usize;
166        }
167        if let Some(delay) = opts.max_reconnect_delay {
168            cfg.max_reconnect_delay = delay as u64;
169        }
170        cfg.node_name = self.node_name.clone();
171        Some(cfg)
172    }
173
174    /// Parse a `TraceConfig` from a YAML file
175    pub fn from_yaml(path: &Path) -> Result<Self> {
176        let content =
177            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
178        Self::from_yaml_str(&content)
179    }
180
181    /// Parse a `TraceConfig` from a YAML string
182    pub fn from_yaml_str(yaml: &str) -> Result<Self> {
183        let raw: RawConfig = serde_yaml::from_str(yaml).context("parsing TraceConfig YAML")?;
184        Ok(raw.into_trace_config())
185    }
186}
187
188// ---------------------------------------------------------------------------
189// Raw YAML deserialisation helpers
190// ---------------------------------------------------------------------------
191
192/// Raw YAML structure before conversion
193#[derive(Debug, Deserialize)]
194#[serde(rename_all = "PascalCase")]
195struct RawConfig {
196    #[serde(default)]
197    trace_options: BTreeMap<String, RawNamespaceOptions>,
198    #[serde(default)]
199    node_name: Option<String>,
200    // We intentionally ignore unknown fields (UseTraceDispatcher, etc.)
201}
202
203#[derive(Debug, Deserialize)]
204#[serde(rename_all = "camelCase")]
205struct RawNamespaceOptions {
206    severity: Option<RawSeverity>,
207    detail: Option<RawDetailLevel>,
208    #[serde(default)]
209    backends: Vec<String>,
210    max_frequency: Option<f64>,
211}
212
213#[derive(Debug, Deserialize)]
214#[serde(rename_all = "PascalCase")]
215enum RawSeverity {
216    Debug,
217    Info,
218    Notice,
219    Warning,
220    Error,
221    Critical,
222    Alert,
223    Emergency,
224    Silence,
225}
226
227#[derive(Debug, Deserialize)]
228#[allow(clippy::enum_variant_names)] // Haskell-compatible names: DMinimal, DNormal, etc.
229enum RawDetailLevel {
230    DMinimal,
231    DNormal,
232    DDetailed,
233    DMaximum,
234}
235
236impl RawConfig {
237    fn into_trace_config(self) -> TraceConfig {
238        let mut options: BTreeMap<Vec<String>, Vec<ConfigOption>> = BTreeMap::new();
239
240        for (key, raw_opts) in self.trace_options {
241            // "" → [], "ChainDB.AddBlock" → ["ChainDB", "AddBlock"]
242            let ns_key: Vec<String> = if key.is_empty() {
243                vec![]
244            } else {
245                key.split('.').map(|s| s.to_string()).collect()
246            };
247
248            let mut opts = Vec::new();
249
250            if let Some(sev) = raw_opts.severity {
251                opts.push(ConfigOption::Severity(sev.into()));
252            }
253            if let Some(det) = raw_opts.detail {
254                opts.push(ConfigOption::Detail(det.into()));
255            }
256            if !raw_opts.backends.is_empty() {
257                let backends: Vec<BackendConfig> = raw_opts
258                    .backends
259                    .iter()
260                    .filter_map(|s| parse_backend(s))
261                    .collect();
262                if !backends.is_empty() {
263                    opts.push(ConfigOption::Backends(backends));
264                }
265            }
266            if let Some(freq) = raw_opts.max_frequency {
267                opts.push(ConfigOption::Limiter(freq));
268            }
269
270            if !opts.is_empty() {
271                options.insert(ns_key, opts);
272            }
273        }
274
275        TraceConfig {
276            options,
277            forwarder: None,
278            node_name: self.node_name,
279        }
280    }
281}
282
283impl From<RawSeverity> for SeverityF {
284    fn from(r: RawSeverity) -> Self {
285        match r {
286            RawSeverity::Debug => SeverityF(Some(Severity::Debug)),
287            RawSeverity::Info => SeverityF(Some(Severity::Info)),
288            RawSeverity::Notice => SeverityF(Some(Severity::Notice)),
289            RawSeverity::Warning => SeverityF(Some(Severity::Warning)),
290            RawSeverity::Error => SeverityF(Some(Severity::Error)),
291            RawSeverity::Critical => SeverityF(Some(Severity::Critical)),
292            RawSeverity::Alert => SeverityF(Some(Severity::Alert)),
293            RawSeverity::Emergency => SeverityF(Some(Severity::Emergency)),
294            RawSeverity::Silence => SeverityF(None),
295        }
296    }
297}
298
299impl From<RawDetailLevel> for DetailLevel {
300    fn from(r: RawDetailLevel) -> Self {
301        match r {
302            RawDetailLevel::DMinimal => DetailLevel::DMinimal,
303            RawDetailLevel::DNormal => DetailLevel::DNormal,
304            RawDetailLevel::DDetailed => DetailLevel::DDetailed,
305            RawDetailLevel::DMaximum => DetailLevel::DMaximum,
306        }
307    }
308}
309
310fn parse_backend(s: &str) -> Option<BackendConfig> {
311    match s.trim() {
312        "Forwarder" => Some(BackendConfig::Forwarder),
313        "EKGBackend" => Some(BackendConfig::EkgBackend),
314        "DatapointBackend" => Some(BackendConfig::DatapointBackend),
315        "Stdout HumanFormatColoured" => {
316            Some(BackendConfig::Stdout(FormatLogging::HumanFormatColoured))
317        }
318        "Stdout HumanFormatUncoloured" => {
319            Some(BackendConfig::Stdout(FormatLogging::HumanFormatUncoloured))
320        }
321        "Stdout MachineFormat" => Some(BackendConfig::Stdout(FormatLogging::MachineFormat)),
322        other => {
323            tracing::warn!("Unknown backend config string: {:?}", other);
324            None
325        }
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    const SAMPLE_YAML: &str = r#"
334UseTraceDispatcher: True
335
336TraceOptions:
337  "":
338    severity: Notice
339    detail: DNormal
340    backends:
341      - Stdout MachineFormat
342      - EKGBackend
343      - Forwarder
344
345  ChainDB:
346    severity: Info
347
348  ChainDB.AddBlockEvent.AddedBlockToQueue:
349    maxFrequency: 2.0
350"#;
351
352    #[test]
353    fn test_parse_yaml() {
354        let cfg = TraceConfig::from_yaml_str(SAMPLE_YAML).unwrap();
355
356        // Global default
357        let global = cfg.options.get(&vec![] as &Vec<String>).unwrap();
358        assert!(
359            global
360                .iter()
361                .any(|o| matches!(o, ConfigOption::Severity(_)))
362        );
363        assert!(
364            global
365                .iter()
366                .any(|o| matches!(o, ConfigOption::Backends(_)))
367        );
368
369        // ChainDB severity
370        let chaindb = cfg.options.get(&vec!["ChainDB".to_string()]).unwrap();
371        assert!(
372            chaindb
373                .iter()
374                .any(|o| matches!(o, ConfigOption::Severity(SeverityF(Some(Severity::Info)))))
375        );
376
377        // Rate limiter
378        let limiter_key = vec![
379            "ChainDB".to_string(),
380            "AddBlockEvent".to_string(),
381            "AddedBlockToQueue".to_string(),
382        ];
383        let limiter_opts = cfg.options.get(&limiter_key).unwrap();
384        assert!(
385            limiter_opts
386                .iter()
387                .any(|o| matches!(o, ConfigOption::Limiter(_)))
388        );
389    }
390
391    #[test]
392    fn test_longest_prefix_match() {
393        let cfg = TraceConfig::from_yaml_str(SAMPLE_YAML).unwrap();
394
395        // Exact key "ChainDB" → Info
396        let sev = cfg.severity_for(&["ChainDB".to_string()]);
397        assert_eq!(sev, SeverityF(Some(Severity::Info)));
398
399        // Subnamespace falls back to "ChainDB"
400        let sev2 = cfg.severity_for(&["ChainDB".to_string(), "SomeChild".to_string()]);
401        assert_eq!(sev2, SeverityF(Some(Severity::Info)));
402
403        // Unknown namespace falls back to global default (Notice)
404        let sev3 = cfg.severity_for(&["Unknown".to_string()]);
405        assert_eq!(sev3, SeverityF(Some(Severity::Notice)));
406    }
407
408    #[test]
409    fn test_backends_parsing() {
410        let cfg = TraceConfig::from_yaml_str(SAMPLE_YAML).unwrap();
411        let backends = cfg.backends_for(&[]);
412        assert!(backends.contains(&BackendConfig::Forwarder));
413        assert!(backends.contains(&BackendConfig::Stdout(FormatLogging::MachineFormat)));
414        assert!(backends.contains(&BackendConfig::EkgBackend));
415    }
416}