monitord/
json.rs

1//! # json module
2//!
3//! `json` is in charge of generating a flat BTreeMap like . serperated hierarchical
4//! JSON output. This is used by some metric parsing systems when running a command.
5
6use std::collections::BTreeMap;
7use std::collections::HashMap;
8
9use tracing::debug;
10
11use crate::dbus_stats;
12use crate::networkd;
13use crate::pid1;
14use crate::units;
15use crate::MachineStats;
16use crate::MonitordStats;
17
18/// Add a prefix if the config specifies one
19fn gen_base_metric_key(key_prefix: &str, metric_name: &str) -> String {
20    match key_prefix.is_empty() {
21        true => String::from(metric_name),
22        false => format!("{}.{}", key_prefix, metric_name),
23    }
24}
25
26fn flatten_networkd(
27    networkd_stats: &networkd::NetworkdState,
28    key_prefix: &str,
29) -> Vec<(String, serde_json::Value)> {
30    let mut flat_stats = vec![];
31    let base_metric_name = gen_base_metric_key(key_prefix, "networkd");
32
33    let managed_interfaces_key = format!("{}.managed_interfaces", base_metric_name);
34    flat_stats.push((
35        managed_interfaces_key,
36        networkd_stats.managed_interfaces.into(),
37    ));
38
39    if networkd_stats.interfaces_state.is_empty() {
40        debug!("No networkd interfaces to add to flat JSON");
41        return flat_stats;
42    }
43
44    for interface in &networkd_stats.interfaces_state {
45        let interface_base = format!("{}.{}", base_metric_name, interface.name);
46        flat_stats.push((
47            format!("{interface_base}.address_state"),
48            (interface.address_state as u64).into(),
49        ));
50        flat_stats.push((
51            format!("{interface_base}.admin_state"),
52            (interface.admin_state as u64).into(),
53        ));
54        flat_stats.push((
55            format!("{interface_base}.carrier_state"),
56            (interface.carrier_state as u64).into(),
57        ));
58        flat_stats.push((
59            format!("{interface_base}.ipv4_address_state"),
60            (interface.ipv4_address_state as u64).into(),
61        ));
62        flat_stats.push((
63            format!("{interface_base}.ipv6_address_state"),
64            (interface.ipv6_address_state as u64).into(),
65        ));
66        flat_stats.push((
67            format!("{interface_base}.oper_state"),
68            (interface.oper_state as u64).into(),
69        ));
70        flat_stats.push((
71            format!("{interface_base}.required_for_online"),
72            (interface.required_for_online as u64).into(),
73        ));
74    }
75    flat_stats
76}
77
78fn flatten_pid1(
79    optional_pid1_stats: &Option<pid1::Pid1Stats>,
80    key_prefix: &str,
81) -> Vec<(String, serde_json::Value)> {
82    // If we're not collecting pid1 stats don't add
83    let pid1_stats = match optional_pid1_stats {
84        Some(ps) => ps,
85        None => {
86            debug!("Skipping flattening pid1 stats as we got None ...");
87            return Vec::new();
88        }
89    };
90
91    let base_metric_name = gen_base_metric_key(key_prefix, "pid1");
92
93    vec![
94        (
95            format!("{}.cpu_time_kernel", base_metric_name),
96            pid1_stats.cpu_time_kernel.into(),
97        ),
98        (
99            format!("{}.cpu_user_kernel", base_metric_name),
100            pid1_stats.cpu_time_user.into(),
101        ),
102        (
103            format!("{}.memory_usage_bytes", base_metric_name),
104            pid1_stats.memory_usage_bytes.into(),
105        ),
106        (
107            format!("{}.fd_count", base_metric_name),
108            pid1_stats.fd_count.into(),
109        ),
110        (
111            format!("{}.tasks", base_metric_name),
112            pid1_stats.tasks.into(),
113        ),
114    ]
115}
116
117fn flatten_services(
118    service_stats_hash: &HashMap<String, units::ServiceStats>,
119    key_prefix: &str,
120) -> Vec<(String, serde_json::Value)> {
121    let mut flat_stats = Vec::new();
122    let base_metric_name = gen_base_metric_key(key_prefix, "services");
123
124    for (service_name, service_stats) in service_stats_hash.iter() {
125        if let Ok(serde_json::Value::Object(map)) = serde_json::to_value(service_stats) {
126            for (field_name, value) in map {
127                if value.is_number() {
128                    let key = format!("{base_metric_name}.{service_name}.{field_name}");
129                    flat_stats.push((key, value));
130                }
131            }
132        }
133    }
134    flat_stats
135}
136
137fn flatten_timers(
138    timer_stats_hash: &HashMap<String, crate::timer::TimerStats>,
139    key_prefix: &str,
140) -> Vec<(String, serde_json::Value)> {
141    let mut flat_stats = Vec::new();
142    let base_metric_name = gen_base_metric_key(key_prefix, "timers");
143
144    for (timer_name, timer_stats) in timer_stats_hash.iter() {
145        if let Ok(serde_json::Value::Object(map)) = serde_json::to_value(timer_stats) {
146            for (field_name, value) in map {
147                let key = format!("{base_metric_name}.{timer_name}.{field_name}");
148                if value.is_number() {
149                    flat_stats.push((key, value));
150                } else if let Some(b) = value.as_bool() {
151                    flat_stats.push((key, (b as u64).into()));
152                }
153            }
154        }
155    }
156    flat_stats
157}
158
159fn flatten_unit_states(
160    unit_states_hash: &HashMap<String, units::UnitStates>,
161    key_prefix: &str,
162) -> Vec<(String, serde_json::Value)> {
163    let mut flat_stats = Vec::new();
164    let base_metric_name = gen_base_metric_key(key_prefix, "unit_states");
165
166    for (unit_name, unit_state_stats) in unit_states_hash.iter() {
167        if let Ok(serde_json::Value::Object(map)) = serde_json::to_value(unit_state_stats) {
168            for (field_name, value) in map {
169                let key = format!("{base_metric_name}.{unit_name}.{field_name}");
170                if value.is_number() {
171                    flat_stats.push((key, value));
172                } else if let Some(b) = value.as_bool() {
173                    flat_stats.push((key, (b as u64).into()));
174                }
175            }
176        }
177    }
178
179    flat_stats
180}
181
182/// Lightweight view of `SystemdUnitStats` containing only the numeric counters.
183/// Used by `flatten_units` to avoid serializing the nested `service_stats`,
184/// `timer_stats`, and `unit_states` hashmaps, keeping flattening O(number_of_counters).
185#[derive(serde::Serialize)]
186struct UnitCounters {
187    activating_units: u64,
188    active_units: u64,
189    automount_units: u64,
190    device_units: u64,
191    failed_units: u64,
192    inactive_units: u64,
193    jobs_queued: u64,
194    loaded_units: u64,
195    masked_units: u64,
196    mount_units: u64,
197    not_found_units: u64,
198    path_units: u64,
199    scope_units: u64,
200    service_units: u64,
201    slice_units: u64,
202    socket_units: u64,
203    target_units: u64,
204    timer_units: u64,
205    timer_persistent_units: u64,
206    timer_remain_after_elapse: u64,
207    total_units: u64,
208}
209
210impl From<&units::SystemdUnitStats> for UnitCounters {
211    fn from(s: &units::SystemdUnitStats) -> Self {
212        Self {
213            activating_units: s.activating_units,
214            active_units: s.active_units,
215            automount_units: s.automount_units,
216            device_units: s.device_units,
217            failed_units: s.failed_units,
218            inactive_units: s.inactive_units,
219            jobs_queued: s.jobs_queued,
220            loaded_units: s.loaded_units,
221            masked_units: s.masked_units,
222            mount_units: s.mount_units,
223            not_found_units: s.not_found_units,
224            path_units: s.path_units,
225            scope_units: s.scope_units,
226            service_units: s.service_units,
227            slice_units: s.slice_units,
228            socket_units: s.socket_units,
229            target_units: s.target_units,
230            timer_units: s.timer_units,
231            timer_persistent_units: s.timer_persistent_units,
232            timer_remain_after_elapse: s.timer_remain_after_elapse,
233            total_units: s.total_units,
234        }
235    }
236}
237
238fn flatten_units(
239    units_stats: &units::SystemdUnitStats,
240    key_prefix: &str,
241) -> Vec<(String, serde_json::Value)> {
242    let mut flat_stats = Vec::new();
243    let base_metric_name = gen_base_metric_key(key_prefix, "units");
244
245    if let Ok(serde_json::Value::Object(map)) =
246        serde_json::to_value(UnitCounters::from(units_stats))
247    {
248        for (field_name, value) in map {
249            if value.is_number() {
250                let key = format!("{base_metric_name}.{field_name}");
251                flat_stats.push((key, value));
252            }
253        }
254    }
255    flat_stats
256}
257
258fn flatten_machines(
259    machines_stats: &HashMap<String, MachineStats>,
260    key_prefix: &str,
261) -> BTreeMap<String, serde_json::Value> {
262    let mut flat_stats = BTreeMap::new();
263
264    if machines_stats.is_empty() {
265        return flat_stats;
266    }
267
268    for (machine, stats) in machines_stats {
269        let machine_key_prefix = match key_prefix.is_empty() {
270            true => format!("machines.{}", machine),
271            false => format!("{}.machines.{}", key_prefix, machine),
272        };
273        flat_stats.extend(flatten_networkd(&stats.networkd, &machine_key_prefix));
274        flat_stats.extend(flatten_units(&stats.units, &machine_key_prefix));
275        flat_stats.extend(flatten_pid1(&stats.pid1, &machine_key_prefix));
276        flat_stats.insert(
277            gen_base_metric_key(&machine_key_prefix, "system-state"),
278            (stats.system_state as u64).into(),
279        );
280        flat_stats.extend(flatten_services(
281            &stats.units.service_stats,
282            &machine_key_prefix,
283        ));
284        flat_stats.extend(flatten_timers(
285            &stats.units.timer_stats,
286            &machine_key_prefix,
287        ));
288        flat_stats.extend(flatten_boot_blame(&stats.boot_blame, &machine_key_prefix));
289        flat_stats.extend(flatten_verify_stats(
290            &stats.verify_stats,
291            &machine_key_prefix,
292        ));
293    }
294
295    flat_stats
296}
297
298fn flatten_dbus_stats(
299    optional_dbus_stats: &Option<dbus_stats::DBusStats>,
300    key_prefix: &str,
301) -> BTreeMap<String, serde_json::Value> {
302    let mut flat_stats: BTreeMap<String, serde_json::Value> = BTreeMap::new();
303    let dbus_stats = match optional_dbus_stats {
304        Some(ds) => ds,
305        None => {
306            debug!("Skipping flattening dbus stats as we got None ...");
307            return flat_stats;
308        }
309    };
310
311    let base_metric_name = gen_base_metric_key(key_prefix, "dbus");
312    let fields = [
313        // ignore serial
314        ("active_connections", dbus_stats.active_connections),
315        ("incomplete_connections", dbus_stats.incomplete_connections),
316        ("bus_names", dbus_stats.bus_names),
317        ("peak_bus_names", dbus_stats.peak_bus_names),
318        (
319            "peak_bus_names_per_connection",
320            dbus_stats.peak_bus_names_per_connection,
321        ),
322        ("match_rules", dbus_stats.match_rules),
323        ("peak_match_rules", dbus_stats.peak_match_rules),
324        (
325            "peak_match_rules_per_connection",
326            dbus_stats.peak_match_rules_per_connection,
327        ),
328    ];
329
330    for (field_name, value) in fields {
331        if let Some(val) = value {
332            flat_stats.insert(format!("{base_metric_name}.{field_name}"), val.into());
333        }
334    }
335
336    if let Some(peer_accounting) = dbus_stats.peer_accounting() {
337        for peer in peer_accounting.values() {
338            let peer_name = peer.get_name();
339            let peer_fields = [
340                ("name_objects", peer.name_objects),
341                ("match_bytes", peer.match_bytes),
342                ("matches", peer.matches),
343                ("reply_objects", peer.reply_objects),
344                ("incoming_bytes", peer.incoming_bytes),
345                ("incoming_fds", peer.incoming_fds),
346                ("outgoing_bytes", peer.outgoing_bytes),
347                ("outgoing_fds", peer.outgoing_fds),
348                ("activation_request_bytes", peer.activation_request_bytes),
349                ("activation_request_fds", peer.activation_request_fds),
350            ];
351
352            for (field_name, value) in peer_fields {
353                if let Some(val) = value {
354                    flat_stats.insert(
355                        format!("{base_metric_name}.peer.{peer_name}.{field_name}"),
356                        val.into(),
357                    );
358                }
359            }
360        }
361    }
362
363    if let Some(cgroup_accounting) = dbus_stats.cgroup_accounting() {
364        for cgroup in cgroup_accounting.values() {
365            let cgroup_name = &cgroup.name;
366            let cgroup_fields = [
367                ("name_objects", cgroup.name_objects),
368                ("match_bytes", cgroup.match_bytes),
369                ("matches", cgroup.matches),
370                ("reply_objects", cgroup.reply_objects),
371                ("incoming_bytes", cgroup.incoming_bytes),
372                ("incoming_fds", cgroup.incoming_fds),
373                ("outgoing_bytes", cgroup.outgoing_bytes),
374                ("outgoing_fds", cgroup.outgoing_fds),
375                ("activation_request_bytes", cgroup.activation_request_bytes),
376                ("activation_request_fds", cgroup.activation_request_fds),
377            ];
378
379            for (field_name, value) in cgroup_fields {
380                if let Some(val) = value {
381                    flat_stats.insert(
382                        format!("{base_metric_name}.cgroup.{cgroup_name}.{field_name}"),
383                        val.into(),
384                    );
385                }
386            }
387        }
388    }
389
390    if let Some(user_accounting) = dbus_stats.user_accounting() {
391        // process user accounting if present
392        for user in user_accounting.values() {
393            let user_name = &user.username;
394            let user_fields = [
395                ("bytes", user.bytes.clone()),
396                ("fds", user.fds.clone()),
397                ("matches", user.matches.clone()),
398                ("objects", user.objects.clone()),
399            ];
400
401            for (field_name, value) in user_fields {
402                if let Some(val) = value {
403                    flat_stats.insert(
404                        format!("{base_metric_name}.user.{user_name}.{field_name}"),
405                        val.get_usage().into(),
406                    );
407                }
408            }
409        }
410    }
411
412    flat_stats
413}
414
415fn flatten_boot_blame(
416    optional_boot_blame: &Option<crate::boot::BootBlameStats>,
417    key_prefix: &str,
418) -> BTreeMap<String, serde_json::Value> {
419    let mut flat_stats: BTreeMap<String, serde_json::Value> = BTreeMap::new();
420    let boot_blame_stats = match optional_boot_blame {
421        Some(bb) => bb,
422        None => {
423            debug!("Skipping flattening boot blame stats as we got None ...");
424            return flat_stats;
425        }
426    };
427
428    let base_metric_name = gen_base_metric_key(key_prefix, "boot.blame");
429
430    for (unit_name, activation_time) in boot_blame_stats.iter() {
431        let key = format!("{}.{}", base_metric_name, unit_name);
432        flat_stats.insert(key, (*activation_time).into());
433    }
434
435    flat_stats
436}
437
438fn flatten_verify_stats(
439    optional_verify_stats: &Option<crate::verify::VerifyStats>,
440    key_prefix: &str,
441) -> BTreeMap<String, serde_json::Value> {
442    let mut flat_stats: BTreeMap<String, serde_json::Value> = BTreeMap::new();
443    let verify_stats = match optional_verify_stats {
444        Some(vs) => vs,
445        None => {
446            debug!("Skipping flattening verify stats as we got None ...");
447            return flat_stats;
448        }
449    };
450
451    let base_metric_name = gen_base_metric_key(key_prefix, "verify.failing");
452
453    // Add total count
454    flat_stats.insert(
455        format!("{base_metric_name}.total"),
456        verify_stats.total.into(),
457    );
458
459    // Add counts by type (only if they exist)
460    for (unit_type, count) in &verify_stats.by_type {
461        flat_stats.insert(format!("{base_metric_name}.{unit_type}"), (*count).into());
462    }
463
464    flat_stats
465}
466
467/// Take the standard returned structs and move all to a flat BTreeMap<str, float|int> like JSON
468fn flatten_stats(
469    stats_struct: &MonitordStats,
470    key_prefix: &str,
471) -> BTreeMap<String, serde_json::Value> {
472    let mut flat_stats: BTreeMap<String, serde_json::Value> = BTreeMap::new();
473    flat_stats.extend(flatten_networkd(&stats_struct.networkd, key_prefix));
474    flat_stats.extend(flatten_pid1(&stats_struct.pid1, key_prefix));
475    flat_stats.insert(
476        gen_base_metric_key(key_prefix, "system-state"),
477        (stats_struct.system_state as u64).into(),
478    );
479    flat_stats.extend(flatten_services(
480        &stats_struct.units.service_stats,
481        key_prefix,
482    ));
483    flat_stats.extend(flatten_timers(&stats_struct.units.timer_stats, key_prefix));
484    flat_stats.extend(flatten_unit_states(
485        &stats_struct.units.unit_states,
486        key_prefix,
487    ));
488    flat_stats.extend(flatten_units(&stats_struct.units, key_prefix));
489    flat_stats.insert(
490        gen_base_metric_key(key_prefix, "version"),
491        stats_struct.version.to_string().into(),
492    );
493    flat_stats.extend(flatten_machines(&stats_struct.machines, key_prefix));
494    flat_stats.extend(flatten_dbus_stats(&stats_struct.dbus_stats, key_prefix));
495    flat_stats.extend(flatten_boot_blame(&stats_struct.boot_blame, key_prefix));
496    flat_stats.extend(flatten_verify_stats(&stats_struct.verify_stats, key_prefix));
497    flat_stats
498}
499
500/// Take the standard returned structs and move all to a flat JSON str
501pub fn flatten(
502    stats_struct: &MonitordStats,
503    key_prefix: &str,
504) -> Result<String, serde_json::Error> {
505    serde_json::to_string_pretty(&flatten_stats(stats_struct, key_prefix))
506}
507
508#[cfg(test)]
509mod tests {
510    use crate::timer;
511
512    use super::*;
513
514    // This will always be sorted / deterministic ...
515    const EXPECTED_FLAT_JSON: &str = r###"{
516  "boot.blame.cpe_chef.service": 103.05,
517  "boot.blame.dnf5-automatic.service": 204.159,
518  "boot.blame.sys-module-fuse.device": 16.21,
519  "machines.foo.networkd.managed_interfaces": 0,
520  "machines.foo.system-state": 0,
521  "machines.foo.timers.unittest.timer.accuracy_usec": 69,
522  "machines.foo.timers.unittest.timer.fixed_random_delay": 1,
523  "machines.foo.timers.unittest.timer.last_trigger_usec": 69,
524  "machines.foo.timers.unittest.timer.last_trigger_usec_monotonic": 69,
525  "machines.foo.timers.unittest.timer.next_elapse_usec_monotonic": 69,
526  "machines.foo.timers.unittest.timer.next_elapse_usec_realtime": 69,
527  "machines.foo.timers.unittest.timer.persistent": 0,
528  "machines.foo.timers.unittest.timer.randomized_delay_usec": 69,
529  "machines.foo.timers.unittest.timer.remain_after_elapse": 1,
530  "machines.foo.timers.unittest.timer.service_unit_last_state_change_usec": 69,
531  "machines.foo.timers.unittest.timer.service_unit_last_state_change_usec_monotonic": 69,
532  "machines.foo.units.activating_units": 0,
533  "machines.foo.units.active_units": 0,
534  "machines.foo.units.automount_units": 0,
535  "machines.foo.units.device_units": 0,
536  "machines.foo.units.failed_units": 0,
537  "machines.foo.units.inactive_units": 0,
538  "machines.foo.units.jobs_queued": 0,
539  "machines.foo.units.loaded_units": 0,
540  "machines.foo.units.masked_units": 0,
541  "machines.foo.units.mount_units": 0,
542  "machines.foo.units.not_found_units": 0,
543  "machines.foo.units.path_units": 0,
544  "machines.foo.units.scope_units": 0,
545  "machines.foo.units.service_units": 0,
546  "machines.foo.units.slice_units": 0,
547  "machines.foo.units.socket_units": 0,
548  "machines.foo.units.target_units": 0,
549  "machines.foo.units.timer_persistent_units": 0,
550  "machines.foo.units.timer_remain_after_elapse": 0,
551  "machines.foo.units.timer_units": 0,
552  "machines.foo.units.total_units": 0,
553  "networkd.eth0.address_state": 3,
554  "networkd.eth0.admin_state": 4,
555  "networkd.eth0.carrier_state": 5,
556  "networkd.eth0.ipv4_address_state": 3,
557  "networkd.eth0.ipv6_address_state": 2,
558  "networkd.eth0.oper_state": 9,
559  "networkd.eth0.required_for_online": 1,
560  "networkd.managed_interfaces": 1,
561  "pid1.cpu_time_kernel": 69,
562  "pid1.cpu_user_kernel": 69,
563  "pid1.fd_count": 69,
564  "pid1.memory_usage_bytes": 69,
565  "pid1.tasks": 1,
566  "services.unittest.service.active_enter_timestamp": 0,
567  "services.unittest.service.active_exit_timestamp": 0,
568  "services.unittest.service.cpuusage_nsec": 0,
569  "services.unittest.service.inactive_exit_timestamp": 0,
570  "services.unittest.service.ioread_bytes": 0,
571  "services.unittest.service.ioread_operations": 0,
572  "services.unittest.service.memory_available": 0,
573  "services.unittest.service.memory_current": 0,
574  "services.unittest.service.nrestarts": 0,
575  "services.unittest.service.processes": 0,
576  "services.unittest.service.restart_usec": 0,
577  "services.unittest.service.state_change_timestamp": 0,
578  "services.unittest.service.status_errno": -69,
579  "services.unittest.service.tasks_current": 0,
580  "services.unittest.service.timeout_clean_usec": 0,
581  "services.unittest.service.watchdog_usec": 0,
582  "system-state": 3,
583  "timers.unittest.timer.accuracy_usec": 69,
584  "timers.unittest.timer.fixed_random_delay": 1,
585  "timers.unittest.timer.last_trigger_usec": 69,
586  "timers.unittest.timer.last_trigger_usec_monotonic": 69,
587  "timers.unittest.timer.next_elapse_usec_monotonic": 69,
588  "timers.unittest.timer.next_elapse_usec_realtime": 69,
589  "timers.unittest.timer.persistent": 0,
590  "timers.unittest.timer.randomized_delay_usec": 69,
591  "timers.unittest.timer.remain_after_elapse": 1,
592  "timers.unittest.timer.service_unit_last_state_change_usec": 69,
593  "timers.unittest.timer.service_unit_last_state_change_usec_monotonic": 69,
594  "unit_states.nvme\\x2dWDC_CL_SN730_SDBQNTY\\x2d512G\\x2d2020_37222H80070511\\x2dpart3.device.active_state": 1,
595  "unit_states.nvme\\x2dWDC_CL_SN730_SDBQNTY\\x2d512G\\x2d2020_37222H80070511\\x2dpart3.device.load_state": 1,
596  "unit_states.nvme\\x2dWDC_CL_SN730_SDBQNTY\\x2d512G\\x2d2020_37222H80070511\\x2dpart3.device.unhealthy": 0,
597  "unit_states.unittest.service.active_state": 1,
598  "unit_states.unittest.service.load_state": 1,
599  "unit_states.unittest.service.time_in_state_usecs": 69,
600  "unit_states.unittest.service.unhealthy": 0,
601  "units.activating_units": 0,
602  "units.active_units": 0,
603  "units.automount_units": 0,
604  "units.device_units": 0,
605  "units.failed_units": 0,
606  "units.inactive_units": 0,
607  "units.jobs_queued": 0,
608  "units.loaded_units": 0,
609  "units.masked_units": 0,
610  "units.mount_units": 0,
611  "units.not_found_units": 0,
612  "units.path_units": 0,
613  "units.scope_units": 0,
614  "units.service_units": 0,
615  "units.slice_units": 0,
616  "units.socket_units": 0,
617  "units.target_units": 0,
618  "units.timer_persistent_units": 0,
619  "units.timer_remain_after_elapse": 0,
620  "units.timer_units": 0,
621  "units.total_units": 0,
622  "verify.failing.service": 2,
623  "verify.failing.slice": 1,
624  "verify.failing.total": 3,
625  "version": "255.7-1.fc40"
626}"###;
627
628    fn return_monitord_stats() -> MonitordStats {
629        let mut stats = MonitordStats {
630            networkd: networkd::NetworkdState {
631                interfaces_state: vec![networkd::InterfaceState {
632                    address_state: networkd::AddressState::routable,
633                    admin_state: networkd::AdminState::configured,
634                    carrier_state: networkd::CarrierState::carrier,
635                    ipv4_address_state: networkd::AddressState::routable,
636                    ipv6_address_state: networkd::AddressState::degraded,
637                    name: "eth0".to_string(),
638                    network_file: "/etc/systemd/network/69-eno4.network".to_string(),
639                    oper_state: networkd::OperState::routable,
640                    required_for_online: networkd::BoolState::True,
641                }],
642                managed_interfaces: 1,
643            },
644            pid1: Some(crate::pid1::Pid1Stats {
645                cpu_time_kernel: 69,
646                cpu_time_user: 69,
647                memory_usage_bytes: 69,
648                fd_count: 69,
649                tasks: 1,
650            }),
651            system_state: crate::system::SystemdSystemState::running,
652            units: crate::units::SystemdUnitStats::default(),
653            version: String::from("255.7-1.fc40")
654                .try_into()
655                .expect("Unable to make SystemdVersion struct"),
656            machines: HashMap::from([(String::from("foo"), MachineStats::default())]),
657            dbus_stats: None,
658            boot_blame: None,
659            verify_stats: Some(crate::verify::VerifyStats {
660                total: 3,
661                by_type: HashMap::from([("service".to_string(), 2), ("slice".to_string(), 1)]),
662            }),
663        };
664        let service_unit_name = String::from("unittest.service");
665        stats.units.service_stats.insert(
666            service_unit_name.clone(),
667            units::ServiceStats {
668                // Ensure json-flat handles negative i32s
669                status_errno: -69,
670                ..Default::default()
671            },
672        );
673        stats.units.unit_states.insert(
674            String::from("unittest.service"),
675            units::UnitStates {
676                active_state: units::SystemdUnitActiveState::active,
677                load_state: units::SystemdUnitLoadState::loaded,
678                unhealthy: false,
679                time_in_state_usecs: Some(69),
680            },
681        );
682        let timer_unit = String::from("unittest.timer");
683        let timer_stats = timer::TimerStats {
684            accuracy_usec: 69,
685            fixed_random_delay: true,
686            last_trigger_usec: 69,
687            last_trigger_usec_monotonic: 69,
688            next_elapse_usec_monotonic: 69,
689            next_elapse_usec_realtime: 69,
690            persistent: false,
691            randomized_delay_usec: 69,
692            remain_after_elapse: true,
693            service_unit_last_state_change_usec: 69,
694            service_unit_last_state_change_usec_monotonic: 69,
695        };
696        stats
697            .units
698            .timer_stats
699            .insert(timer_unit.clone(), timer_stats.clone());
700        stats
701            .machines
702            .get_mut("foo")
703            .expect("No machine foo? WTF")
704            .units
705            .timer_stats
706            .insert(timer_unit, timer_stats);
707        // Ensure we escape keys correctly
708        stats.units.unit_states.insert(
709            String::from(
710                r"nvme\x2dWDC_CL_SN730_SDBQNTY\x2d512G\x2d2020_37222H80070511\x2dpart3.device",
711            ),
712            units::UnitStates {
713                active_state: units::SystemdUnitActiveState::active,
714                load_state: units::SystemdUnitLoadState::loaded,
715                unhealthy: false,
716                time_in_state_usecs: None,
717            },
718        );
719        // Add boot blame stats
720        let mut boot_blame = crate::boot::BootBlameStats::new();
721        boot_blame.insert(String::from("dnf5-automatic.service"), 204.159);
722        boot_blame.insert(String::from("cpe_chef.service"), 103.050);
723        boot_blame.insert(String::from("sys-module-fuse.device"), 16.210);
724        stats.boot_blame = Some(boot_blame);
725        stats
726    }
727
728    #[test]
729    fn test_flatten_map() {
730        let json_flat_map = flatten_stats(&return_monitord_stats(), "");
731        assert_eq!(110, json_flat_map.len());
732    }
733
734    #[test]
735    fn test_flatten() {
736        let json_flat = flatten(&return_monitord_stats(), "").expect("JSON serialize failed");
737        assert_eq!(EXPECTED_FLAT_JSON, json_flat);
738    }
739
740    #[test]
741    fn test_flatten_prefixed() {
742        let json_flat =
743            flatten(&return_monitord_stats(), "monitord").expect("JSON serialize failed");
744        let json_flat_unserialized: BTreeMap<String, serde_json::Value> =
745            serde_json::from_str(&json_flat).expect("JSON from_str failed");
746        for (key, _value) in json_flat_unserialized.iter() {
747            assert!(key.starts_with("monitord."));
748        }
749    }
750
751    /// Ensure `UnitCounters` covers every scalar (non-hashmap) field of `SystemdUnitStats`.
752    ///
753    /// If a new counter field is added to `SystemdUnitStats` but not to `UnitCounters`
754    /// (and its `From` impl), this test will fail, preventing silent omissions from the
755    /// flat JSON output.
756    #[test]
757    fn test_unit_counters_covers_all_scalar_fields() {
758        // Fields of SystemdUnitStats that are nested maps, not scalar counters.
759        const NON_COUNTER_FIELDS: &[&str] = &["service_stats", "timer_stats", "unit_states"];
760
761        // Scalar counter field names expected from SystemdUnitStats.
762        let expected: std::collections::BTreeSet<&str> = units::UNIT_FIELD_NAMES
763            .iter()
764            .copied()
765            .filter(|f| !NON_COUNTER_FIELDS.contains(f))
766            .collect();
767
768        // Field names actually present in UnitCounters (via serde serialization).
769        let counters_json =
770            serde_json::to_value(UnitCounters::from(&units::SystemdUnitStats::default()))
771                .expect("UnitCounters serialization failed");
772        let actual: std::collections::BTreeSet<&str> = counters_json
773            .as_object()
774            .expect("UnitCounters must serialize to a JSON object")
775            .keys()
776            .map(|s| s.as_str())
777            .collect();
778
779        assert_eq!(
780            expected,
781            actual,
782            "UnitCounters is out of sync with SystemdUnitStats scalar fields.\n\
783             Missing from UnitCounters: {:?}\n\
784             Extra in UnitCounters: {:?}",
785            expected.difference(&actual).collect::<Vec<_>>(),
786            actual.difference(&expected).collect::<Vec<_>>(),
787        );
788    }
789}