1use 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#[derive(Default)]
26struct UnitActivation {
27 active_enter_usec: u64,
29 inactive_exit_usec: u64,
31}
32
33fn 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
92fn 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
120fn has_activation_metrics(metrics: &[ListOutput]) -> bool {
129 metrics
130 .iter()
131 .any(|metric| metric.name_suffix() == "ActiveTimestamp")
132}
133
134pub 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 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 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 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 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}