Skip to main content

monitord/
units.rs

1//! # units module
2//!
3//! All main systemd unit statistics. Counts of types of units, unit states and
4//! queued jobs. We also house service specific statistics and system unit states.
5
6use std::collections::HashMap;
7use std::str::FromStr;
8use std::sync::Arc;
9use std::time::Instant;
10use std::time::SystemTime;
11use std::time::UNIX_EPOCH;
12
13use struct_field_names_as_array::FieldNamesAsArray;
14use thiserror::Error;
15use tokio::sync::RwLock;
16use tokio::sync::Semaphore;
17use tokio::task::JoinSet;
18use tracing::debug;
19use tracing::error;
20use tracing::warn;
21use tracing::Instrument;
22use zbus::zvariant::ObjectPath;
23use zbus::zvariant::OwnedObjectPath;
24
25#[derive(Error, Debug)]
26pub enum MonitordUnitsError {
27    #[error("Units D-Bus error: {0}")]
28    ZbusError(#[from] zbus::Error),
29    #[error("Integer conversion error: {0}")]
30    IntConversion(#[from] std::num::TryFromIntError),
31    #[error("System time error: {0}")]
32    SystemTimeError(#[from] std::time::SystemTimeError),
33}
34
35use crate::timer::TimerStats;
36use crate::MachineStats;
37
38// Re-export the enums and function from unit_constants for backwards compatibility
39pub use crate::unit_constants::is_unit_unhealthy;
40pub use crate::unit_constants::is_unit_unhealthy_for_service;
41pub use crate::unit_constants::SystemdUnitActiveState;
42pub use crate::unit_constants::SystemdUnitLoadState;
43pub use crate::unit_constants::SYSTEMD_SERVICE_SUFFIX;
44
45/// Inner timing breakdown for the units collector D-Bus phases.
46///
47/// Helps locate which step of unit collection dominates wall time when the
48/// `units` collector is the slowest one in `MonitordStats::collector_timings`.
49#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, PartialEq)]
50pub struct UnitsCollectionTimings {
51    /// Time for the systemd ListUnits D-Bus call (one batched call returning all units).
52    pub list_units_ms: f64,
53    /// Time for filesystem unit file stats collection (runs concurrently with list_units).
54    pub unit_files_ms: f64,
55    /// Time spent in the per-unit parse loop, including any per-unit D-Bus calls
56    /// (timer property fetches, state stats, service stats).
57    pub per_unit_loop_ms: f64,
58    /// Number of timer units whose properties were fetched via D-Bus this run.
59    pub timer_dbus_fetches: u64,
60    /// Number of unit state D-Bus fetches this run (when state_stats_time_in_state is enabled).
61    pub state_dbus_fetches: u64,
62    /// Number of per-service D-Bus property fetches this run.
63    pub service_dbus_fetches: u64,
64    /// Slowest units (by per-unit collection duration, descending) this run,
65    /// truncated to `units.slowest_units_count`. Empty when disabled (count 0).
66    pub slowest_units: Vec<(String, f64)>,
67}
68
69/// Unit file counts for a scope (root or user), broken down by unit type.
70#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, PartialEq)]
71pub struct UnitFilesScope {
72    /// Generated unit files by type (e.g. "service" => 2, "mount" => 5)
73    pub generated: HashMap<String, u64>,
74    /// Transient unit files by type (e.g. "service" => 10, "scope" => 6)
75    pub transient: HashMap<String, u64>,
76}
77
78/// Unit file statistics collected from the filesystem for root and user scopes.
79#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, PartialEq)]
80pub struct UnitFilesStats {
81    pub root: UnitFilesScope,
82    pub user: UnitFilesScope,
83}
84
85#[derive(
86    serde::Serialize, serde::Deserialize, Clone, Debug, Default, FieldNamesAsArray, PartialEq,
87)]
88
89/// Aggregated systemd unit statistics: counts by type, load state, active state,
90/// plus optional per-service and per-timer detailed metrics
91pub struct SystemdUnitStats {
92    /// Number of units in the "activating" state (in the process of being started)
93    pub activating_units: u64,
94    /// Number of units in the "active" state (currently started and running)
95    pub active_units: u64,
96    /// Number of automount units (on-demand filesystem mount points)
97    pub automount_units: u64,
98    /// Number of device units (kernel devices exposed to systemd by udev)
99    pub device_units: u64,
100    /// Number of units in the "failed" state (exited with error, crashed, or timed out)
101    pub failed_units: u64,
102    /// Number of units in the "inactive" state (not currently running)
103    pub inactive_units: u64,
104    /// Number of pending jobs queued in the systemd job scheduler
105    pub jobs_queued: u64,
106    /// Number of units whose unit file has been successfully loaded into memory
107    pub loaded_units: u64,
108    /// Number of units whose unit file is masked (symlinked to /dev/null, cannot be started)
109    pub masked_units: u64,
110    /// Number of mount units (filesystem mount points managed by systemd)
111    pub mount_units: u64,
112    /// Number of units whose unit file could not be found on disk
113    pub not_found_units: u64,
114    /// Number of path units (file/directory watch triggers)
115    pub path_units: u64,
116    /// Number of scope units (externally created process groups, e.g. user sessions)
117    pub scope_units: u64,
118    /// Number of service units (daemon/process lifecycle management)
119    pub service_units: u64,
120    /// Number of slice units (resource management groups in the cgroup hierarchy)
121    pub slice_units: u64,
122    /// Number of socket units (IPC/network socket activation endpoints)
123    pub socket_units: u64,
124    /// Number of target units (synchronization points for grouping units)
125    pub target_units: u64,
126    /// Number of timer units (calendar/monotonic scheduled triggers)
127    pub timer_units: u64,
128    /// Number of timer units with Persistent=yes (triggers missed runs after downtime)
129    pub timer_persistent_units: u64,
130    /// Number of timer units with RemainAfterElapse=yes (stays loaded after firing)
131    pub timer_remain_after_elapse: u64,
132    /// Total number of units known to systemd (all types, all states)
133    pub total_units: u64,
134    /// Unit file statistics from the filesystem (e.g. generator output counts)
135    pub unit_files: UnitFilesStats,
136    /// Per-service detailed metrics keyed by unit name (e.g. "sshd.service")
137    pub service_stats: HashMap<String, ServiceStats>,
138    /// Per-timer detailed metrics keyed by unit name (e.g. "logrotate.timer")
139    pub timer_stats: HashMap<String, TimerStats>,
140    /// Per-unit active/load state tracking keyed by unit name
141    pub unit_states: HashMap<String, UnitStates>,
142    /// Inner timing breakdown for this collector. Zero-valued before the first
143    /// run completes or when the varlink path is taken.
144    pub collection_timings: UnitsCollectionTimings,
145}
146
147/// Per-service metrics from the org.freedesktop.systemd1.Service and Unit D-Bus interfaces.
148/// Ref: <https://www.freedesktop.org/software/systemd/man/org.freedesktop.systemd1.html>
149#[derive(
150    serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, FieldNamesAsArray, PartialEq,
151)]
152pub struct ServiceStats {
153    /// Realtime timestamp (usec since epoch) when the unit most recently entered the active state
154    pub active_enter_timestamp: u64,
155    /// Realtime timestamp (usec since epoch) when the unit most recently left the active state
156    pub active_exit_timestamp: u64,
157    /// Total CPU time consumed by this service's cgroup in nanoseconds
158    pub cpuusage_nsec: u64,
159    /// Realtime timestamp (usec since epoch) when the unit most recently left the inactive state
160    pub inactive_exit_timestamp: u64,
161    /// Total bytes read from block I/O by this service's cgroup
162    pub ioread_bytes: u64,
163    /// Total number of block I/O read operations by this service's cgroup
164    pub ioread_operations: u64,
165    /// Memory available to the service (MemoryAvailable from cgroup), in bytes
166    pub memory_available: u64,
167    /// Current memory usage of the service's cgroup in bytes
168    pub memory_current: u64,
169    /// Number of times systemd has restarted this service (automatic restarts)
170    pub nrestarts: u32,
171    /// Current number of processes in this service's cgroup
172    pub processes: u32,
173    /// Configured restart delay for this service in microseconds (RestartUSec)
174    pub restart_usec: u64,
175    /// Realtime timestamp (usec since epoch) of the most recent state change of any kind
176    pub state_change_timestamp: u64,
177    /// errno-style exit status code from the main process (0 = success)
178    pub status_errno: i32,
179    /// Current number of tasks (threads) in this service's cgroup
180    pub tasks_current: u64,
181    /// Timeout in microseconds for the cleanup of resources after the service exits
182    pub timeout_clean_usec: u64,
183    /// Watchdog timeout in microseconds; the service must ping within this interval or be killed
184    pub watchdog_usec: u64,
185}
186
187/// Per-unit state tracking combining active state, load state, and computed health.
188/// Ref: <https://www.freedesktop.org/software/systemd/man/org.freedesktop.systemd1.html>
189#[derive(
190    serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, FieldNamesAsArray, PartialEq,
191)]
192pub struct UnitStates {
193    /// Current active state of the unit (active, inactive, failed, activating, deactivating, reloading)
194    pub active_state: SystemdUnitActiveState,
195    /// Current load state of the unit (loaded, error, masked, not_found)
196    pub load_state: SystemdUnitLoadState,
197    /// Computed health flag: true when a loaded unit is not active, or when load state is error/not_found.
198    /// Masked units are never marked unhealthy since masking is an intentional admin action.
199    /// Optional config can ignore inactive oneshot services.
200    pub unhealthy: bool,
201    /// Microseconds elapsed since the unit's most recent state change.
202    /// None when time-in-state tracking is disabled in config (expensive D-Bus lookup per unit).
203    pub time_in_state_usecs: Option<u64>,
204}
205
206// Declare state types
207// Reference: https://www.freedesktop.org/software/systemd/man/org.freedesktop.systemd1.html
208// SubState can be unit-type-specific so can't enum
209
210#[derive(Debug)]
211pub struct ListedUnit {
212    pub name: String,                      // The primary unit name
213    pub description: String,               // The human readable description
214    pub load_state: String, // The load state (i.e. whether the unit file has been loaded successfully)
215    pub active_state: String, // The active state (i.e. whether the unit is currently started or not)
216    pub sub_state: String,    // The sub state (i.e. unit type more specific state)
217    pub follow_unit: String, // A unit that is being followed in its state by this unit, if there is any, otherwise the empty string
218    pub unit_object_path: OwnedObjectPath, // The unit object path
219    pub job_id: u32, // If there is a job queued for the job unit, the numeric job id, 0 otherwise
220    pub job_type: String, // The job type as string
221    pub job_object_path: OwnedObjectPath, // The job object path
222}
223impl
224    From<(
225        String,
226        String,
227        String,
228        String,
229        String,
230        String,
231        OwnedObjectPath,
232        u32,
233        String,
234        OwnedObjectPath,
235    )> for ListedUnit
236{
237    fn from(
238        tuple: (
239            String,
240            String,
241            String,
242            String,
243            String,
244            String,
245            OwnedObjectPath,
246            u32,
247            String,
248            OwnedObjectPath,
249        ),
250    ) -> Self {
251        ListedUnit {
252            name: tuple.0,
253            description: tuple.1,
254            load_state: tuple.2,
255            active_state: tuple.3,
256            sub_state: tuple.4,
257            follow_unit: tuple.5,
258            unit_object_path: tuple.6,
259            job_id: tuple.7,
260            job_type: tuple.8,
261            job_object_path: tuple.9,
262        }
263    }
264}
265
266pub const SERVICE_FIELD_NAMES: &[&str] = &ServiceStats::FIELD_NAMES_AS_ARRAY;
267pub const UNIT_FIELD_NAMES: &[&str] = &SystemdUnitStats::FIELD_NAMES_AS_ARRAY;
268pub const UNIT_STATES_FIELD_NAMES: &[&str] = &UnitStates::FIELD_NAMES_AS_ARRAY;
269
270/// Pull out selected systemd service statistics
271#[tracing::instrument(level = "debug", skip(connection, object_path))]
272async fn parse_service(
273    connection: &zbus::Connection,
274    name: &str,
275    object_path: &OwnedObjectPath,
276) -> Result<ServiceStats, MonitordUnitsError> {
277    debug!("Parsing service {} stats", name);
278
279    let sp = crate::dbus::zbus_service::ServiceProxy::builder(connection)
280        .cache_properties(zbus::proxy::CacheProperties::No)
281        .path(object_path.clone())?
282        .build()
283        .await?;
284    let up = crate::dbus::zbus_unit::UnitProxy::builder(connection)
285        .cache_properties(zbus::proxy::CacheProperties::No)
286        .path(object_path.clone())?
287        .build()
288        .await?;
289
290    // Use tokio::join! without tokio::spawn to avoid per-task allocation overhead.
291    // These all share the same D-Bus connection so spawn adds no parallelism benefit.
292    let (
293        active_enter_timestamp,
294        active_exit_timestamp,
295        cpuusage_nsec,
296        inactive_exit_timestamp,
297        ioread_bytes,
298        ioread_operations,
299        memory_current,
300        memory_available,
301        nrestarts,
302        processes,
303        restart_usec,
304        state_change_timestamp,
305        status_errno,
306        tasks_current,
307        timeout_clean_usec,
308        watchdog_usec,
309    ) = tokio::join!(
310        up.active_enter_timestamp(),
311        up.active_exit_timestamp(),
312        sp.cpuusage_nsec(),
313        up.inactive_exit_timestamp(),
314        sp.ioread_bytes(),
315        sp.ioread_operations(),
316        sp.memory_current(),
317        sp.memory_available(),
318        sp.nrestarts(),
319        sp.get_processes(),
320        sp.restart_usec(),
321        up.state_change_timestamp(),
322        sp.status_errno(),
323        sp.tasks_current(),
324        sp.timeout_clean_usec(),
325        sp.watchdog_usec(),
326    );
327
328    Ok(ServiceStats {
329        active_enter_timestamp: active_enter_timestamp?,
330        active_exit_timestamp: active_exit_timestamp?,
331        cpuusage_nsec: cpuusage_nsec?,
332        inactive_exit_timestamp: inactive_exit_timestamp?,
333        ioread_bytes: ioread_bytes?,
334        ioread_operations: ioread_operations?,
335        memory_current: memory_current?,
336        memory_available: memory_available?,
337        nrestarts: nrestarts?,
338        processes: processes?.len().try_into()?,
339        restart_usec: restart_usec?,
340        state_change_timestamp: state_change_timestamp?,
341        status_errno: status_errno?,
342        tasks_current: tasks_current?,
343        timeout_clean_usec: timeout_clean_usec?,
344        watchdog_usec: watchdog_usec?,
345    })
346}
347
348#[tracing::instrument(level = "debug", skip(connection))]
349async fn get_time_in_state(
350    connection: Option<&zbus::Connection>,
351    unit: &ListedUnit,
352) -> Result<Option<u64>, MonitordUnitsError> {
353    match connection {
354        Some(c) => {
355            let up = crate::dbus::zbus_unit::UnitProxy::builder(c)
356                .cache_properties(zbus::proxy::CacheProperties::No)
357                .path(ObjectPath::from(unit.unit_object_path.clone()))?
358                .build()
359                .await?;
360            let now: u64 = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() * 1_000_000;
361            let state_change_timestamp = match up.state_change_timestamp().await {
362                Ok(sct) => sct,
363                Err(err) => {
364                    error!(
365                        "Unable to get state_change_timestamp for {} - Setting to 0: {:?}",
366                        &unit.name, err,
367                    );
368                    0
369                }
370            };
371            Ok(Some(now - state_change_timestamp))
372        }
373        None => {
374            error!("No zbus connection passed, but time_in_state_usecs enabled");
375            Ok(None)
376        }
377    }
378}
379
380/// Parse state of a unit into a `UnitStates` entry for the caller to merge.
381///
382/// Returns `(did_dbus_fetch, entry)`: `did_dbus_fetch` is true when an actual
383/// time-in-state D-Bus fetch was performed, so callers can keep
384/// `state_dbus_fetches` honest. Allowlist/blocklist short-circuits return
385/// `(false, None)` — the unit is simply not tracked in `unit_states`.
386#[tracing::instrument(level = "debug", skip(config, connection))]
387pub async fn parse_state(
388    unit: &ListedUnit,
389    config: &crate::config::UnitsConfig,
390    connection: Option<&zbus::Connection>,
391) -> Result<(bool, Option<UnitStates>), MonitordUnitsError> {
392    if config.state_stats_blocklist.contains(&unit.name) {
393        debug!("Skipping state stats for {} due to blocklist", &unit.name);
394        return Ok((false, None));
395    }
396    if !config.state_stats_allowlist.is_empty()
397        && !config.state_stats_allowlist.contains(&unit.name)
398    {
399        return Ok((false, None));
400    }
401    let active_state = SystemdUnitActiveState::from_str(&unit.active_state)
402        .unwrap_or(SystemdUnitActiveState::unknown);
403    let load_state = SystemdUnitLoadState::from_str(&unit.load_state.replace('-', "_"))
404        .unwrap_or(SystemdUnitLoadState::unknown);
405    let mut is_oneshot_service = false;
406    if config.ignore_inactive_oneshot_services
407        && unit.name.ends_with(SYSTEMD_SERVICE_SUFFIX)
408        && matches!(active_state, SystemdUnitActiveState::inactive)
409        && matches!(load_state, SystemdUnitLoadState::loaded)
410    {
411        if let Some(conn) = connection {
412            match is_oneshot_service_unit(conn, unit).await {
413                Ok(is_oneshot) => is_oneshot_service = is_oneshot,
414                Err(err) => warn!(
415                    "Unable to get Service.Type for {} (assuming not oneshot): {:?}",
416                    &unit.name, err
417                ),
418            }
419        }
420    }
421
422    // Get the state_change_timestamp to determine time in usecs we've been in current state
423    let mut time_in_state_usecs: Option<u64> = None;
424    let mut did_dbus_fetch = false;
425    if config.state_stats_time_in_state {
426        time_in_state_usecs = get_time_in_state(connection, unit).await?;
427        // get_time_in_state only issues a D-Bus call when connection is Some;
428        // the None path logs an error and returns Ok(None) without calling out.
429        did_dbus_fetch = connection.is_some();
430    }
431
432    let entry = UnitStates {
433        active_state,
434        load_state,
435        unhealthy: is_unit_unhealthy_for_service(
436            active_state,
437            load_state,
438            is_oneshot_service,
439            config.ignore_inactive_oneshot_services,
440        ),
441        time_in_state_usecs,
442    };
443    Ok((did_dbus_fetch, Some(entry)))
444}
445
446#[tracing::instrument(level = "debug", skip(connection))]
447async fn is_oneshot_service_unit(
448    connection: &zbus::Connection,
449    unit: &ListedUnit,
450) -> Result<bool, MonitordUnitsError> {
451    let sp = crate::dbus::zbus_service::ServiceProxy::builder(connection)
452        .cache_properties(zbus::proxy::CacheProperties::No)
453        .path(ObjectPath::from(unit.unit_object_path.clone()))?
454        .build()
455        .await?;
456    Ok(sp.type_().await? == "oneshot")
457}
458
459/// Parse a unit and add to overall counts of state, type etc.
460fn parse_unit(stats: &mut SystemdUnitStats, unit: &ListedUnit) {
461    // Count unit type
462    match unit.name.rsplit('.').next() {
463        Some("automount") => stats.automount_units += 1,
464        Some("device") => stats.device_units += 1,
465        Some("mount") => stats.mount_units += 1,
466        Some("path") => stats.path_units += 1,
467        Some("scope") => stats.scope_units += 1,
468        Some("service") => stats.service_units += 1,
469        Some("slice") => stats.slice_units += 1,
470        Some("socket") => stats.socket_units += 1,
471        Some("target") => stats.target_units += 1,
472        Some("timer") => stats.timer_units += 1,
473        unknown => debug!("Found unhandled '{:?}' unit type", unknown),
474    };
475    // Count load state
476    match unit.load_state.as_str() {
477        "loaded" => stats.loaded_units += 1,
478        "masked" => stats.masked_units += 1,
479        "not-found" => stats.not_found_units += 1,
480        _ => debug!("{} is not loaded. It's {}", unit.name, unit.load_state),
481    };
482    // Count unit status
483    match unit.active_state.as_str() {
484        "activating" => stats.activating_units += 1,
485        "active" => stats.active_units += 1,
486        "failed" => stats.failed_units += 1,
487        "inactive" => stats.inactive_units += 1,
488        unknown => debug!("Found unhandled '{}' unit state", unknown),
489    };
490    // Count jobs queued
491    if unit.job_id != 0 {
492        stats.jobs_queued += 1;
493    }
494}
495
496const TRANSIENT_DIR: &str = "/run/systemd/transient";
497
498async fn count_unit_files_by_type(path: &str) -> HashMap<String, u64> {
499    let mut dir = match tokio::fs::read_dir(path).await {
500        Ok(d) => d,
501        Err(err) => {
502            debug!("Unable to read {}: {:?}", path, err);
503            return HashMap::new();
504        }
505    };
506    let mut counts = HashMap::new();
507    loop {
508        match dir.next_entry().await {
509            Ok(Some(entry)) => {
510                let file_type = match entry.file_type().await {
511                    Ok(ft) => ft,
512                    Err(_) => continue,
513                };
514                if !file_type.is_file() {
515                    continue;
516                }
517                let name = entry.file_name();
518                let unit_type = name
519                    .to_str()
520                    .and_then(|n| n.rsplit('.').next())
521                    .unwrap_or("unknown");
522                *counts.entry(unit_type.to_string()).or_insert(0) += 1;
523            }
524            Ok(None) => break,
525            Err(err) => {
526                warn!("Error reading entry in {}: {:?}", path, err);
527                continue;
528            }
529        }
530    }
531    counts
532}
533
534fn merge_counts(target: &mut HashMap<String, u64>, source: HashMap<String, u64>) {
535    for (unit_type, count) in source {
536        *target.entry(unit_type).or_insert(0) += count;
537    }
538}
539
540/// Enumerate the per-user systemd transient directories under `{fs_root}/run/user`.
541async fn enumerate_user_transient_dirs(fs_root: &str) -> Vec<String> {
542    let user_dir = format!("{fs_root}/run/user");
543    match tokio::fs::read_dir(&user_dir).await {
544        Ok(mut entries) => {
545            let mut dirs = Vec::new();
546            loop {
547                match entries.next_entry().await {
548                    Ok(Some(entry)) => {
549                        dirs.push(format!("{}/systemd/transient", entry.path().display()));
550                    }
551                    Ok(None) => break,
552                    Err(err) => {
553                        warn!("Error reading entry in {}: {:?}", user_dir, err);
554                        continue;
555                    }
556                }
557            }
558            dirs
559        }
560        Err(err) => {
561            debug!("Unable to read {}: {:?}", user_dir, err);
562            Vec::new()
563        }
564    }
565}
566
567/// Collect unit file statistics from the filesystem.
568/// `fs_root` is prepended to all paths — empty string for the host,
569/// `/proc/<pid>/root` for containers.
570///
571/// All directory reads are issued in parallel: the three generator directories,
572/// the root transient directory, and user-dir enumeration run concurrently in a
573/// first batch; per-user transient reads run concurrently in a second batch.
574pub async fn collect_unit_files_stats(fs_root: &str) -> UnitFilesStats {
575    // Pre-bind formatted paths to extend their lifetime across the join.
576    let gen_path = format!("{fs_root}/run/systemd/generator");
577    let gen_early_path = format!("{fs_root}/run/systemd/generator.early");
578    let gen_late_path = format!("{fs_root}/run/systemd/generator.late");
579    let transient_path = format!("{fs_root}{TRANSIENT_DIR}");
580
581    // First batch: fixed paths + user dir enumeration all in parallel.
582    let (gen, gen_early, gen_late, root_transient, user_dirs) = tokio::join!(
583        count_unit_files_by_type(&gen_path),
584        count_unit_files_by_type(&gen_early_path),
585        count_unit_files_by_type(&gen_late_path),
586        count_unit_files_by_type(&transient_path),
587        enumerate_user_transient_dirs(fs_root),
588    );
589
590    let mut root_generated = HashMap::new();
591    merge_counts(&mut root_generated, gen);
592    merge_counts(&mut root_generated, gen_early);
593    merge_counts(&mut root_generated, gen_late);
594
595    // Second batch: read every user transient directory in parallel.
596    let user_transient_counts =
597        futures_util::future::join_all(user_dirs.iter().map(|d| count_unit_files_by_type(d))).await;
598
599    let mut user_transient = HashMap::new();
600    for counts in user_transient_counts {
601        merge_counts(&mut user_transient, counts);
602    }
603
604    UnitFilesStats {
605        root: UnitFilesScope {
606            generated: root_generated,
607            transient: root_transient,
608        },
609        user: UnitFilesScope {
610            generated: HashMap::new(),
611            transient: user_transient,
612        },
613    }
614}
615
616/// Owned result of one unit's concurrent D-Bus work, merged into `SystemdUnitStats`
617/// by the caller once the spawned task completes. Keeping this owned (rather than
618/// mutating `SystemdUnitStats` from multiple concurrent tasks) avoids needing a
619/// `Mutex`/`RwLock` around it.
620#[derive(Default)]
621struct PerUnitOutcome {
622    unit_name: String,
623    unit_states_entry: Option<UnitStates>,
624    state_dbus_fetch: bool,
625    service_stats_entry: Option<ServiceStats>,
626    timer_stats_entry: Option<TimerStats>,
627    duration_ms: f64,
628}
629
630/// Pull all units from dbus and count how system is setup and behaving
631#[tracing::instrument(level = "debug", skip(config, connection))]
632pub async fn parse_unit_state(
633    config: &Arc<crate::config::Config>,
634    connection: &zbus::Connection,
635    fs_root: &str,
636) -> Result<SystemdUnitStats, MonitordUnitsError> {
637    if !config.units.state_stats_allowlist.is_empty() {
638        debug!(
639            "Using unit state allowlist: {:?}",
640            config.units.state_stats_allowlist
641        );
642    }
643
644    if !config.units.state_stats_blocklist.is_empty() {
645        debug!(
646            "Using unit state blocklist: {:?}",
647            config.units.state_stats_blocklist,
648        );
649    }
650
651    let mut stats = SystemdUnitStats::default();
652
653    let p = crate::dbus::zbus_systemd::ManagerProxy::builder(connection)
654        .cache_properties(zbus::proxy::CacheProperties::No)
655        .build()
656        .await?;
657
658    // Run filesystem collection and D-Bus list_units in parallel, timing each independently.
659    let (unit_files_result, units_result) = tokio::join!(
660        async {
661            let start = Instant::now();
662            let files = if config.units.unit_files {
663                collect_unit_files_stats(fs_root).await
664            } else {
665                UnitFilesStats::default()
666            };
667            (files, start.elapsed().as_secs_f64() * 1000.0)
668        },
669        async {
670            let start = Instant::now();
671            let units = p.list_units().await;
672            (units, start.elapsed().as_secs_f64() * 1000.0)
673        },
674    );
675    let (unit_files, unit_files_ms) = unit_files_result;
676    let (units_result, list_units_ms) = units_result;
677    stats.collection_timings.unit_files_ms = unit_files_ms;
678    stats.collection_timings.list_units_ms = list_units_ms;
679    stats.unit_files = unit_files;
680
681    let units = units_result?;
682    stats.total_units = units.len() as u64;
683
684    let per_unit_loop_start = Instant::now();
685    let mut state_dbus_fetches: u64 = 0;
686    let mut service_dbus_fetches: u64 = 0;
687    let mut timer_dbus_fetches: u64 = 0;
688
689    // Cheap synchronous unit-type/state counting first, separate from the
690    // concurrent D-Bus work below — no .await, so no reason to involve the
691    // per-unit tasks in it.
692    let listed_units: Vec<ListedUnit> = units.into_iter().map(ListedUnit::from).collect();
693    for unit in &listed_units {
694        parse_unit(&mut stats, unit);
695    }
696
697    // Bounded-concurrency D-Bus work per unit. A semaphore (rather than an
698    // unbounded join) caps how many units are in flight at once: a burst of
699    // simultaneous D-Bus calls could itself worsen host-level IPC contention
700    // on hosts where per-call latency is already elevated, which is exactly
701    // the failure mode this loop exists to avoid.
702    let semaphore = Arc::new(Semaphore::new(
703        config.units.per_unit_concurrency.max(1) as usize
704    ));
705    // Captured once, outside the loop: `JoinSet::spawn` runs each unit on a new
706    // task, and tracing spans do not cross task boundaries automatically. Without
707    // attaching this as the explicit parent below, every `unit_collect` span
708    // becomes its own unrelated root trace instead of a child of this collector.
709    let parent_span = tracing::Span::current();
710    let mut join_set: JoinSet<PerUnitOutcome> = JoinSet::new();
711    for unit in listed_units {
712        let semaphore = Arc::clone(&semaphore);
713        let config = Arc::clone(config);
714        let connection = connection.clone();
715        let parent_span = parent_span.clone();
716        join_set.spawn(async move {
717            let _permit = semaphore
718                .acquire()
719                .await
720                .expect("semaphore closed unexpectedly");
721            let unit_collect_start = Instant::now();
722            let span =
723                tracing::debug_span!(parent: &parent_span, "unit_collect", unit = %unit.name);
724            let mut outcome = async {
725                let mut outcome = PerUnitOutcome {
726                    unit_name: unit.name.clone(),
727                    ..Default::default()
728                };
729
730                // Collect per unit state stats - ActiveState + LoadState.
731                // Not collecting SubState (yet). A D-Bus error on one unit
732                // is logged and skipped rather than aborting the whole
733                // collection cycle for every other unit.
734                if config.units.state_stats {
735                    match parse_state(&unit, &config.units, Some(&connection)).await {
736                        Ok((did_fetch, entry)) => {
737                            outcome.state_dbus_fetch = did_fetch;
738                            outcome.unit_states_entry = entry;
739                        }
740                        Err(err) => {
741                            error!("Unable to get state for {}: {:?}", unit.name, err);
742                        }
743                    }
744                }
745
746                // Collect service stats
747                if config.services.contains(&unit.name) {
748                    debug!("Collecting service stats for {:?}", &unit);
749                    match parse_service(&connection, &unit.name, &unit.unit_object_path).await {
750                        Ok(service_stats) => outcome.service_stats_entry = Some(service_stats),
751                        Err(err) => error!(
752                            "Unable to get service stats for {} {}: {:#?}",
753                            &unit.name, &unit.unit_object_path, err
754                        ),
755                    }
756                }
757
758                // Collect timer stats
759                if config.timers.enabled
760                    && unit.name.contains(".timer")
761                    && !config.timers.blocklist.contains(&unit.name)
762                    && (config.timers.allowlist.is_empty()
763                        || config.timers.allowlist.contains(&unit.name))
764                {
765                    match crate::timer::collect_timer_stats(&connection, &unit).await {
766                        Ok(ts) => outcome.timer_stats_entry = Some(ts),
767                        Err(err) => error!("Failed to get {} stats: {:#?}", &unit.name, err),
768                    }
769                }
770
771                outcome
772            }
773            .instrument(span)
774            .await;
775
776            outcome.duration_ms = unit_collect_start.elapsed().as_secs_f64() * 1000.0;
777            outcome
778        });
779    }
780
781    let mut slowest_units: Vec<(String, f64)> = Vec::new();
782    while let Some(res) = join_set.join_next().await {
783        let outcome = match res {
784            Ok(outcome) => outcome,
785            Err(err) => {
786                error!("Per-unit collection task failed to join: {:?}", err);
787                continue;
788            }
789        };
790        if let Some(entry) = outcome.unit_states_entry {
791            stats.unit_states.insert(outcome.unit_name.clone(), entry);
792        }
793        if outcome.state_dbus_fetch {
794            state_dbus_fetches += 1;
795        }
796        if let Some(service_stats) = outcome.service_stats_entry {
797            stats
798                .service_stats
799                .insert(outcome.unit_name.clone(), service_stats);
800            service_dbus_fetches += 1;
801        }
802        if let Some(ts) = outcome.timer_stats_entry {
803            if ts.persistent {
804                stats.timer_persistent_units += 1;
805            }
806            if ts.remain_after_elapse {
807                stats.timer_remain_after_elapse += 1;
808            }
809            stats.timer_stats.insert(outcome.unit_name.clone(), ts);
810            timer_dbus_fetches += 1;
811        }
812        if config.units.slowest_units_count > 0 {
813            slowest_units.push((outcome.unit_name, outcome.duration_ms));
814        }
815    }
816
817    if config.units.slowest_units_count > 0 {
818        slowest_units.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
819        slowest_units.truncate(config.units.slowest_units_count as usize);
820        stats.collection_timings.slowest_units = slowest_units;
821    }
822
823    let per_unit_loop_elapsed = per_unit_loop_start.elapsed();
824    stats.collection_timings.per_unit_loop_ms = per_unit_loop_elapsed.as_secs_f64() * 1000.0;
825    stats.collection_timings.state_dbus_fetches = state_dbus_fetches;
826    stats.collection_timings.service_dbus_fetches = service_dbus_fetches;
827    stats.collection_timings.timer_dbus_fetches = timer_dbus_fetches;
828
829    debug!("unit stats: {:?}", stats);
830    Ok(stats)
831}
832
833/// Async wrapper that can update unit stats when passed a locked struct.
834/// `fs_root` is prepended to filesystem paths for unit file stats —
835/// empty string for the host, `/proc/<pid>/root` for containers.
836///
837/// The whole (potentially hundreds-of-D-Bus-calls) collection runs before the
838/// write lock is taken, not while holding it: `locked_machine_stats` is
839/// shared by every collector, so holding it across the entire per-unit loop
840/// would block every other collector's own (often much quicker) write until
841/// this collection finished.
842pub async fn update_unit_stats(
843    config: Arc<crate::config::Config>,
844    connection: zbus::Connection,
845    locked_machine_stats: Arc<RwLock<MachineStats>>,
846    fs_root: String,
847) -> anyhow::Result<()> {
848    let units_stats = parse_unit_state(&config, &connection, &fs_root).await;
849    let mut machine_stats = locked_machine_stats.write().await;
850    match units_stats {
851        Ok(units_stats) => machine_stats.units = units_stats,
852        Err(err) => error!("units stats failed: {:?}", err),
853    }
854    Ok(())
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860    use std::collections::HashSet;
861    use strum::IntoEnumIterator;
862
863    fn get_unit_file() -> ListedUnit {
864        ListedUnit {
865            name: String::from("apport-autoreport.timer"),
866            description: String::from(
867                "Process error reports when automatic reporting is enabled (timer based)",
868            ),
869            load_state: String::from("loaded"),
870            active_state: String::from("inactive"),
871            sub_state: String::from("dead"),
872            follow_unit: String::from(""),
873            unit_object_path: ObjectPath::try_from(
874                "/org/freedesktop/systemd1/unit/apport_2dautoreport_2etimer",
875            )
876            .expect("Unable to make an object path")
877            .into(),
878            job_id: 0,
879            job_type: String::from(""),
880            job_object_path: ObjectPath::try_from("/").unwrap().into(),
881        }
882    }
883
884    #[tokio::test]
885    async fn test_state_parse() -> Result<(), MonitordUnitsError> {
886        let test_unit_name = String::from("apport-autoreport.timer");
887        let expected_stats = SystemdUnitStats {
888            activating_units: 0,
889            active_units: 0,
890            automount_units: 0,
891            device_units: 0,
892            failed_units: 0,
893            inactive_units: 0,
894            jobs_queued: 0,
895            loaded_units: 0,
896            masked_units: 0,
897            mount_units: 0,
898            not_found_units: 0,
899            path_units: 0,
900            scope_units: 0,
901            service_units: 0,
902            slice_units: 0,
903            socket_units: 0,
904            target_units: 0,
905            timer_units: 0,
906            timer_persistent_units: 0,
907            timer_remain_after_elapse: 0,
908            total_units: 0,
909            unit_files: UnitFilesStats::default(),
910            service_stats: HashMap::new(),
911            timer_stats: HashMap::new(),
912            unit_states: HashMap::from([(
913                test_unit_name.clone(),
914                UnitStates {
915                    active_state: SystemdUnitActiveState::inactive,
916                    load_state: SystemdUnitLoadState::loaded,
917                    unhealthy: true,
918                    time_in_state_usecs: None,
919                },
920            )]),
921            collection_timings: UnitsCollectionTimings::default(),
922        };
923        let mut stats = SystemdUnitStats::default();
924        let systemd_unit = get_unit_file();
925        let mut config = crate::config::UnitsConfig::default();
926
927        // Test no allow list or blocklist; with connection: None, parse_state
928        // takes the no-op path inside get_time_in_state and returns false.
929        let (did_fetch, entry) = parse_state(&systemd_unit, &config, None).await?;
930        if let Some(entry) = entry {
931            stats.unit_states.insert(systemd_unit.name.clone(), entry);
932        }
933        assert_eq!(expected_stats, stats);
934        assert!(!did_fetch);
935
936        // Create an allow list
937        config.state_stats_allowlist = HashSet::from([test_unit_name.clone()]);
938
939        // test no blocklist and only allow list - Should equal the same as no lists above
940        let mut allowlist_stats = SystemdUnitStats::default();
941        let (did_fetch, entry) = parse_state(&systemd_unit, &config, None).await?;
942        if let Some(entry) = entry {
943            allowlist_stats
944                .unit_states
945                .insert(systemd_unit.name.clone(), entry);
946        }
947        assert_eq!(expected_stats, allowlist_stats);
948        assert!(!did_fetch);
949
950        // Now add a blocklist
951        config.state_stats_blocklist = HashSet::from([test_unit_name]);
952
953        // test blocklist with allow list (show it's preferred)
954        let mut blocklist_stats = SystemdUnitStats::default();
955        let expected_blocklist_stats = SystemdUnitStats::default();
956        let (did_fetch, entry) = parse_state(&systemd_unit, &config, None).await?;
957        if let Some(entry) = entry {
958            blocklist_stats
959                .unit_states
960                .insert(systemd_unit.name.clone(), entry);
961        }
962        assert_eq!(expected_blocklist_stats, blocklist_stats);
963        // Blocklist short-circuit must NOT count as a D-Bus fetch.
964        assert!(!did_fetch);
965        Ok(())
966    }
967
968    #[test]
969    fn test_unit_parse() {
970        let expected_stats = SystemdUnitStats {
971            activating_units: 0,
972            active_units: 0,
973            automount_units: 0,
974            device_units: 0,
975            failed_units: 0,
976            inactive_units: 1,
977            jobs_queued: 0,
978            loaded_units: 1,
979            masked_units: 0,
980            mount_units: 0,
981            not_found_units: 0,
982            path_units: 0,
983            scope_units: 0,
984            service_units: 0,
985            slice_units: 0,
986            socket_units: 0,
987            target_units: 0,
988            timer_units: 1,
989            timer_persistent_units: 0,
990            timer_remain_after_elapse: 0,
991            total_units: 0,
992            unit_files: UnitFilesStats::default(),
993            service_stats: HashMap::new(),
994            timer_stats: HashMap::new(),
995            unit_states: HashMap::new(),
996            collection_timings: UnitsCollectionTimings::default(),
997        };
998        let mut stats = SystemdUnitStats::default();
999        let systemd_unit = get_unit_file();
1000        parse_unit(&mut stats, &systemd_unit);
1001        assert_eq!(expected_stats, stats);
1002    }
1003
1004    #[test]
1005    fn test_unit_parse_activating() {
1006        let mut activating_unit = get_unit_file();
1007        activating_unit.active_state = String::from("activating");
1008        let mut stats = SystemdUnitStats::default();
1009        parse_unit(&mut stats, &activating_unit);
1010        assert_eq!(stats.activating_units, 1);
1011        assert_eq!(stats.active_units, 0);
1012        assert_eq!(stats.inactive_units, 0);
1013    }
1014
1015    #[test]
1016    fn test_iterators() {
1017        assert!(SystemdUnitActiveState::iter().collect::<Vec<_>>().len() > 0);
1018        assert!(SystemdUnitLoadState::iter().collect::<Vec<_>>().len() > 0);
1019    }
1020
1021    #[tokio::test]
1022    async fn test_count_unit_files_by_type() {
1023        let dir = tempfile::tempdir().expect("Unable to create temp dir");
1024        let path = dir.path();
1025
1026        std::fs::write(path.join("sshd.service"), "").unwrap();
1027        std::fs::write(path.join("nginx.service"), "").unwrap();
1028        std::fs::write(path.join("boot.mount"), "").unwrap();
1029        std::fs::write(path.join("swap.swap"), "").unwrap();
1030        std::fs::create_dir(path.join("multi-user.target.wants")).unwrap();
1031
1032        let counts = count_unit_files_by_type(path.to_str().unwrap()).await;
1033        assert_eq!(counts.get("service"), Some(&2));
1034        assert_eq!(counts.get("mount"), Some(&1));
1035        assert_eq!(counts.get("swap"), Some(&1));
1036        assert_eq!(counts.get("wants"), None);
1037        assert_eq!(counts.len(), 3);
1038    }
1039
1040    #[tokio::test]
1041    async fn test_count_unit_files_by_type_nonexistent_dir() {
1042        let counts = count_unit_files_by_type("/nonexistent/path").await;
1043        assert!(counts.is_empty());
1044    }
1045
1046    #[tokio::test]
1047    async fn test_collect_unit_files_stats_with_fs_root() {
1048        let root = tempfile::tempdir().expect("Unable to create temp dir");
1049        let root_path = root.path();
1050
1051        let gen_dir = root_path.join("run/systemd/generator");
1052        std::fs::create_dir_all(&gen_dir).unwrap();
1053        std::fs::write(gen_dir.join("boot.mount"), "").unwrap();
1054        std::fs::write(gen_dir.join("swap.swap"), "").unwrap();
1055
1056        let transient_dir = root_path.join("run/systemd/transient");
1057        std::fs::create_dir_all(&transient_dir).unwrap();
1058        std::fs::write(transient_dir.join("run-thing.service"), "").unwrap();
1059
1060        let user_transient = root_path.join("run/user/1000/systemd/transient");
1061        std::fs::create_dir_all(&user_transient).unwrap();
1062        std::fs::write(user_transient.join("app-code.scope"), "").unwrap();
1063        std::fs::write(user_transient.join("app-term.scope"), "").unwrap();
1064
1065        let stats = collect_unit_files_stats(root_path.to_str().unwrap()).await;
1066        assert_eq!(stats.root.generated.get("mount"), Some(&1));
1067        assert_eq!(stats.root.generated.get("swap"), Some(&1));
1068        assert_eq!(stats.root.transient.get("service"), Some(&1));
1069        assert_eq!(stats.user.transient.get("scope"), Some(&2));
1070        assert!(stats.user.generated.is_empty());
1071    }
1072}