Skip to main content

monitord/
varlink_boot.rs

1//! # varlink_boot module
2//!
3//! Boot blame from the `io.systemd.Metrics` stream rather than per-unit D-Bus
4//! property reads. Available from systemd v261+, which is when the metrics
5//! endpoint gained the per-unit `ActiveTimestamp`/`InactiveExitTimestamp`
6//! families this reads.
7//!
8//! The D-Bus path costs one `ListUnits` plus two property reads for *every*
9//! unit on the system — several hundred round trips, which is why boot blame
10//! ships disabled. Here it is one streamed `List`, and only once per boot:
11//! `boot::update_boot_blame_stats` caches the result in memory and on disk
12//! keyed by boot id, so a daemon pays this on its first cycle and never again.
13
14use std::collections::HashMap;
15
16use tracing::debug;
17
18use crate::boot::BootBlameStats;
19use crate::config::BootBlameConfig;
20use crate::varlink::metrics::ListOutput;
21
22pub use crate::varlink_units::METRICS_SOCKET_PATH;
23
24/// The two timestamps a unit's activation time is derived from.
25#[derive(Default)]
26struct UnitActivation {
27    /// When the unit entered the active state.
28    active_enter_usec: u64,
29    /// When it left the inactive state, i.e. when activation began.
30    inactive_exit_usec: u64,
31}
32
33/// Fold the per-unit timestamp metrics into one entry per unit.
34///
35/// `ActiveTimestamp` is emitted twice per unit, distinguished by an `event`
36/// field of `enter` or `exit`; only `enter` is wanted here, matching the D-Bus
37/// path's `ActiveEnterTimestamp` property.
38fn collect_activations(
39    metrics: &[ListOutput],
40    config: &BootBlameConfig,
41) -> HashMap<String, UnitActivation> {
42    let mut activations: HashMap<String, UnitActivation> = HashMap::new();
43    for metric in metrics {
44        let Some(unit_name) = metric.object() else {
45            continue;
46        };
47        if config.blocklist.contains(unit_name) {
48            debug!("Skipping boot blame for {} due to blocklist", unit_name);
49            continue;
50        }
51        if !config.allowlist.is_empty() && !config.allowlist.contains(unit_name) {
52            continue;
53        }
54        if !metric.value().is_i64() {
55            debug!(
56                "Skipping {} for {}: non-integer value {:?}",
57                metric.name(),
58                unit_name,
59                metric.value()
60            );
61            continue;
62        }
63        let Ok(value) = u64::try_from(metric.value_as_int()) else {
64            debug!(
65                "Skipping {} for {}: negative value {}",
66                metric.name(),
67                unit_name,
68                metric.value_as_int()
69            );
70            continue;
71        };
72
73        match metric.name_suffix() {
74            "ActiveTimestamp" if metric.get_field_as_str("event") == Some("enter") => {
75                activations
76                    .entry(unit_name.to_string())
77                    .or_default()
78                    .active_enter_usec = value;
79            }
80            "InactiveExitTimestamp" => {
81                activations
82                    .entry(unit_name.to_string())
83                    .or_default()
84                    .inactive_exit_usec = value;
85            }
86            _ => {}
87        }
88    }
89    activations
90}
91
92/// Rank units by activation time, slowest first, keeping the configured count.
93///
94/// Same arithmetic and same exclusions as `boot::get_unit_activation_time`: a
95/// unit with either timestamp at zero never activated and is dropped, as is one
96/// whose activation rounds to zero seconds.
97fn rank_slowest(
98    activations: HashMap<String, UnitActivation>,
99    num_slowest: usize,
100) -> BootBlameStats {
101    let mut unit_times: Vec<(String, f64)> = activations
102        .into_iter()
103        .filter_map(|(name, activation)| {
104            if activation.active_enter_usec == 0 || activation.inactive_exit_usec == 0 {
105                return None;
106            }
107            let elapsed_usec = activation
108                .active_enter_usec
109                .saturating_sub(activation.inactive_exit_usec);
110            let elapsed_sec = elapsed_usec as f64 / 1_000_000.0;
111            (elapsed_sec > 0.0).then_some((name, elapsed_sec))
112        })
113        .collect();
114
115    unit_times.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
116    unit_times.truncate(num_slowest);
117    unit_times.into_iter().collect()
118}
119
120/// Whether the stream carries the per-unit timestamp families at all.
121///
122/// systemd v260 answers on this socket but predates `ActiveTimestamp` and
123/// `InactiveExitTimestamp`, so those metrics are simply absent and the blame
124/// would come back empty while the call looked like a success — the same
125/// silent-wrong-answer shape the per-unit context guard exists for. A running
126/// systemd always has units, so seeing none of these means the family is
127/// missing rather than the system being idle.
128fn has_activation_metrics(metrics: &[ListOutput]) -> bool {
129    metrics
130        .iter()
131        .any(|metric| metric.name_suffix() == "ActiveTimestamp")
132}
133
134/// Collect boot blame over varlink.
135pub async fn get_boot_blame_stats(
136    socket_path: &str,
137    config: &BootBlameConfig,
138) -> anyhow::Result<BootBlameStats> {
139    let metrics = crate::varlink_units::collect_metrics(socket_path.to_string()).await?;
140    if !has_activation_metrics(&metrics) {
141        anyhow::bail!(
142            "metrics carry no ActiveTimestamp family: this systemd is older than v261, \
143             where the per-unit boot timestamps were added"
144        );
145    }
146    let activations = collect_activations(&metrics, config);
147    Ok(rank_slowest(activations, config.num_slowest_units as usize))
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use std::collections::HashSet;
154
155    fn metric(name: &str, unit: &str, value: i64, event: Option<&str>) -> ListOutput {
156        ListOutput {
157            name: format!("io.systemd.Manager.{name}"),
158            value: serde_json::json!(value),
159            object: Some(unit.to_string()),
160            fields: event.map(|event| {
161                std::collections::HashMap::from([("event".to_string(), serde_json::json!(event))])
162            }),
163        }
164    }
165
166    fn unit_metrics(unit: &str, inactive_exit: i64, active_enter: i64) -> Vec<ListOutput> {
167        vec![
168            metric("InactiveExitTimestamp", unit, inactive_exit, None),
169            metric("ActiveTimestamp", unit, active_enter, Some("enter")),
170            // systemd emits the exit event too; it must not be mistaken for the
171            // enter one, which would invert the calculation.
172            metric("ActiveTimestamp", unit, 0, Some("exit")),
173        ]
174    }
175
176    fn config() -> BootBlameConfig {
177        BootBlameConfig {
178            enabled: true,
179            num_slowest_units: 5,
180            ..Default::default()
181        }
182    }
183
184    #[test]
185    fn test_ranks_slowest_units_first() {
186        let mut metrics = unit_metrics("slow.service", 1_000_000, 4_000_000);
187        metrics.extend(unit_metrics("quick.service", 1_000_000, 1_500_000));
188        metrics.extend(unit_metrics("middling.service", 1_000_000, 3_000_000));
189
190        let stats = rank_slowest(collect_activations(&metrics, &config()), 5);
191
192        assert_eq!(stats.len(), 3);
193        assert_eq!(stats.get("slow.service"), Some(&3.0));
194        assert_eq!(stats.get("middling.service"), Some(&2.0));
195        assert_eq!(stats.get("quick.service"), Some(&0.5));
196    }
197
198    #[test]
199    fn test_truncates_to_the_configured_count() {
200        let mut metrics = unit_metrics("a.service", 1_000_000, 9_000_000);
201        metrics.extend(unit_metrics("b.service", 1_000_000, 5_000_000));
202        metrics.extend(unit_metrics("c.service", 1_000_000, 2_000_000));
203
204        let stats = rank_slowest(collect_activations(&metrics, &config()), 2);
205
206        assert_eq!(stats.len(), 2);
207        assert!(stats.contains_key("a.service"));
208        assert!(stats.contains_key("b.service"));
209        assert!(!stats.contains_key("c.service"));
210    }
211
212    #[test]
213    fn test_units_that_never_activated_are_dropped() {
214        // Either timestamp at zero means the unit never activated, exactly as
215        // the D-Bus path treats it.
216        let mut metrics = unit_metrics("never-ran.service", 0, 0);
217        metrics.extend(unit_metrics("no-enter.service", 1_000_000, 0));
218        metrics.extend(unit_metrics("no-exit.service", 0, 4_000_000));
219        // A unit whose activation is below a microsecond rounds to zero and is
220        // dropped rather than reported as an instant unit.
221        metrics.extend(unit_metrics("instant.service", 1_000_000, 1_000_000));
222
223        let stats = rank_slowest(collect_activations(&metrics, &config()), 5);
224
225        assert!(stats.is_empty(), "got {stats:?}");
226    }
227
228    #[test]
229    fn test_pre_v261_metrics_are_rejected() {
230        // v260 answers on this socket but has no per-unit timestamp families,
231        // so the stream parses fine and yields nothing. Without this the blame
232        // would report empty and never fall back to D-Bus. CI only runs
233        // Rawhide, so this shape has to be pinned here.
234        let v260_shaped = vec![ListOutput {
235            name: "io.systemd.Manager.UnitsTotal".to_string(),
236            value: serde_json::json!(295),
237            object: None,
238            fields: None,
239        }];
240        assert!(!has_activation_metrics(&v260_shaped));
241
242        assert!(has_activation_metrics(&unit_metrics(
243            "foo.service",
244            1_000_000,
245            2_000_000
246        )));
247    }
248
249    #[test]
250    fn test_allowlist_and_blocklist() {
251        let mut metrics = unit_metrics("wanted.service", 1_000_000, 3_000_000);
252        metrics.extend(unit_metrics("blocked.service", 1_000_000, 9_000_000));
253        metrics.extend(unit_metrics("unlisted.service", 1_000_000, 8_000_000));
254
255        let blocked = BootBlameConfig {
256            blocklist: HashSet::from(["blocked.service".to_string()]),
257            ..config()
258        };
259        let stats = rank_slowest(collect_activations(&metrics, &blocked), 5);
260        assert!(!stats.contains_key("blocked.service"));
261        assert!(stats.contains_key("unlisted.service"));
262
263        let allowed = BootBlameConfig {
264            allowlist: HashSet::from(["wanted.service".to_string()]),
265            ..config()
266        };
267        let stats = rank_slowest(collect_activations(&metrics, &allowed), 5);
268        assert_eq!(stats.len(), 1);
269        assert!(stats.contains_key("wanted.service"));
270    }
271}