vector/sources/host_metrics/
filesystem.rs

1use futures::StreamExt;
2use heim::units::information::byte;
3#[cfg(not(windows))]
4use heim::units::ratio::ratio;
5#[cfg(unix)]
6use nix::sys::statvfs::statvfs;
7use vector_lib::{configurable::configurable_component, metric_tags};
8
9use super::{FilterList, HostMetrics, default_all_devices, example_devices, filter_result};
10use crate::internal_events::{HostMetricsScrapeDetailError, HostMetricsScrapeFilesystemError};
11
12/// Options for the filesystem metrics collector.
13#[configurable_component]
14#[derive(Clone, Debug, Default)]
15pub struct FilesystemConfig {
16    /// Lists of device name patterns to include or exclude in gathering
17    /// usage metrics.
18    #[serde(default = "default_all_devices")]
19    #[configurable(metadata(docs::examples = "example_devices()"))]
20    devices: FilterList,
21
22    /// Lists of filesystem name patterns to include or exclude in gathering
23    /// usage metrics.
24    #[serde(default = "default_all_devices")]
25    #[configurable(metadata(docs::examples = "example_filesystems()"))]
26    filesystems: FilterList,
27
28    /// Lists of mount point path patterns to include or exclude in gathering
29    /// usage metrics.
30    #[serde(default = "default_all_devices")]
31    #[configurable(metadata(docs::examples = "example_mountpoints()"))]
32    mountpoints: FilterList,
33}
34
35fn example_filesystems() -> FilterList {
36    FilterList {
37        includes: Some(vec!["ntfs".try_into().unwrap()]),
38        excludes: Some(vec!["ext*".try_into().unwrap()]),
39    }
40}
41
42fn example_mountpoints() -> FilterList {
43    FilterList {
44        includes: Some(vec!["/home".try_into().unwrap()]),
45        excludes: Some(vec!["/raid*".try_into().unwrap()]),
46    }
47}
48
49impl HostMetrics {
50    pub async fn filesystem_metrics(&self, output: &mut super::MetricsBuffer) {
51        output.name = "filesystem";
52        match heim::disk::partitions().await {
53            Ok(partitions) => {
54                for (partition, usage) in partitions
55                    .filter_map(|result| {
56                        filter_result(result, "Failed to load/parse partition data.")
57                    })
58                    // Filter on configured mountpoints
59                    .map(|partition| {
60                        self.config
61                            .filesystem
62                            .mountpoints
63                            .contains_path(Some(partition.mount_point()))
64                            .then_some(partition)
65                    })
66                    .filter_map(|partition| async { partition })
67                    // Filter on configured devices
68                    .map(|partition| {
69                        self.config
70                            .filesystem
71                            .devices
72                            .contains_path(partition.device().map(|d| d.as_ref()))
73                            .then_some(partition)
74                    })
75                    .filter_map(|partition| async { partition })
76                    // Filter on configured filesystems
77                    .map(|partition| {
78                        self.config
79                            .filesystem
80                            .filesystems
81                            .contains_str(Some(partition.file_system().as_str()))
82                            .then_some(partition)
83                    })
84                    .filter_map(|partition| async { partition })
85                    // Load usage from the partition mount point
86                    .filter_map(|partition| async {
87                        heim::disk::usage(partition.mount_point())
88                            .await
89                            .map_err(|error| {
90                                emit!(HostMetricsScrapeFilesystemError {
91                                    message: "Failed to load partitions info.",
92                                    mount_point: partition
93                                        .mount_point()
94                                        .to_str()
95                                        .unwrap_or("unknown")
96                                        .to_string(),
97                                    error,
98                                })
99                            })
100                            .map(|usage| (partition, usage))
101                            .ok()
102                    })
103                    .collect::<Vec<_>>()
104                    .await
105                {
106                    let fs = partition.file_system();
107                    let mut tags = metric_tags! {
108                        "filesystem" => fs.as_str(),
109                        "mountpoint" => partition.mount_point().to_string_lossy()
110                    };
111                    if let Some(device) = partition.device() {
112                        tags.replace("device".into(), device.to_string_lossy().to_string());
113                    }
114                    output.gauge(
115                        "filesystem_free_bytes",
116                        usage.free().get::<byte>() as f64,
117                        tags.clone(),
118                    );
119                    output.gauge(
120                        "filesystem_total_bytes",
121                        usage.total().get::<byte>() as f64,
122                        tags.clone(),
123                    );
124                    output.gauge(
125                        "filesystem_used_bytes",
126                        usage.used().get::<byte>() as f64,
127                        tags.clone(),
128                    );
129                    #[cfg(not(windows))]
130                    output.gauge(
131                        "filesystem_used_ratio",
132                        usage.ratio().get::<ratio>() as f64,
133                        tags.clone(),
134                    );
135
136                    // inode metrics via a second statvfs call - heim's Usage wraps
137                    // libc::statvfs internally but doesn't expose inode fields
138                    // (f_files, f_ffree). the kernel caches statvfs for local
139                    // filesystems so the overhead is negligible, but network mounts
140                    // may pay a small extra cost.
141                    #[cfg(unix)]
142                    if let Ok(stat) = statvfs(partition.mount_point()) {
143                        let inodes_total = stat.files() as f64;
144                        let inodes_free = stat.files_free() as f64;
145                        let inodes_used = (inodes_total - inodes_free).max(0.0);
146                        let inodes_used_ratio = if inodes_total > 0.0 {
147                            inodes_used / inodes_total
148                        } else {
149                            0.0
150                        };
151
152                        output.gauge("filesystem_inodes_total", inodes_total, tags.clone());
153                        output.gauge("filesystem_inodes_free", inodes_free, tags.clone());
154                        output.gauge("filesystem_inodes_used", inodes_used, tags.clone());
155                        output.gauge("filesystem_inodes_used_ratio", inodes_used_ratio, tags);
156                    }
157                    #[cfg(windows)]
158                    drop(tags);
159                }
160            }
161            Err(error) => {
162                emit!(HostMetricsScrapeDetailError {
163                    message: "Failed to load partitions info.",
164                    error,
165                });
166            }
167        }
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::{
174        super::{
175            HostMetrics, HostMetricsConfig, MetricsBuffer,
176            tests::{all_gauges, assert_filtered_metrics, count_name, count_tag},
177        },
178        FilesystemConfig,
179    };
180
181    #[cfg(not(windows))]
182    #[tokio::test]
183    async fn generates_filesystem_metrics() {
184        let mut buffer = MetricsBuffer::new(None);
185        HostMetrics::new(HostMetricsConfig::default())
186            .filesystem_metrics(&mut buffer)
187            .await;
188        let metrics = buffer.metrics;
189        assert!(!metrics.is_empty());
190        assert!(all_gauges(&metrics));
191
192        // Base metrics (these are always present)
193        let base_metrics = [
194            "filesystem_free_bytes",
195            "filesystem_total_bytes",
196            "filesystem_used_bytes",
197            "filesystem_used_ratio",
198        ];
199
200        // Each filesystem should have all 4 base metrics
201        let num_filesystems = count_name(&metrics, "filesystem_free_bytes");
202        assert!(num_filesystems > 0);
203        for name in &base_metrics {
204            assert_eq!(count_name(&metrics, name), num_filesystems, "name={name}");
205        }
206
207        // Inode metrics are present for filesystems that support statvfs
208        // (some virtual filesystems like /proc, /sys might not)
209        let inode_metrics = [
210            "filesystem_inodes_total",
211            "filesystem_inodes_free",
212            "filesystem_inodes_used",
213            "filesystem_inodes_used_ratio",
214        ];
215        let num_inode_total = count_name(&metrics, "filesystem_inodes_total");
216        assert!(
217            num_inode_total > 0,
218            "Expected at least one filesystem to report inode metrics"
219        );
220
221        // For filesystems that report inodes, all 4 inode metrics should be present
222        for name in &inode_metrics {
223            assert_eq!(count_name(&metrics, name), num_inode_total, "name={name}");
224        }
225
226        // They should all have "filesystem" and "mountpoint" tags
227        assert_eq!(count_tag(&metrics, "filesystem"), metrics.len());
228        assert_eq!(count_tag(&metrics, "mountpoint"), metrics.len());
229    }
230
231    #[cfg(windows)]
232    #[tokio::test]
233    async fn generates_filesystem_metrics() {
234        let mut buffer = MetricsBuffer::new(None);
235        HostMetrics::new(HostMetricsConfig::default())
236            .filesystem_metrics(&mut buffer)
237            .await;
238        let metrics = buffer.metrics;
239        assert!(!metrics.is_empty());
240        assert!(metrics.len() % 3 == 0);
241        assert!(all_gauges(&metrics));
242
243        // There are exactly three filesystem_* names
244        for name in &[
245            "filesystem_free_bytes",
246            "filesystem_total_bytes",
247            "filesystem_used_bytes",
248        ] {
249            assert_eq!(
250                count_name(&metrics, name),
251                metrics.len() / 3,
252                "name={}",
253                name
254            );
255        }
256
257        // They should all have "filesystem" and "mountpoint" tags
258        assert_eq!(count_tag(&metrics, "filesystem"), metrics.len());
259        assert_eq!(count_tag(&metrics, "mountpoint"), metrics.len());
260    }
261
262    #[tokio::test]
263    async fn filesystem_metrics_filters_on_device() {
264        assert_filtered_metrics("device", |devices| async move {
265            let mut buffer = MetricsBuffer::new(None);
266            HostMetrics::new(HostMetricsConfig {
267                filesystem: FilesystemConfig {
268                    devices,
269                    ..Default::default()
270                },
271                ..Default::default()
272            })
273            .filesystem_metrics(&mut buffer)
274            .await;
275            buffer.metrics
276        })
277        .await;
278    }
279
280    #[tokio::test]
281    async fn filesystem_metrics_filters_on_filesystem() {
282        assert_filtered_metrics("filesystem", |filesystems| async move {
283            let mut buffer = MetricsBuffer::new(None);
284            HostMetrics::new(HostMetricsConfig {
285                filesystem: FilesystemConfig {
286                    filesystems,
287                    ..Default::default()
288                },
289                ..Default::default()
290            })
291            .filesystem_metrics(&mut buffer)
292            .await;
293            buffer.metrics
294        })
295        .await;
296    }
297
298    #[tokio::test]
299    async fn filesystem_metrics_filters_on_mountpoint() {
300        assert_filtered_metrics("mountpoint", |mountpoints| async move {
301            let mut buffer = MetricsBuffer::new(None);
302            HostMetrics::new(HostMetricsConfig {
303                filesystem: FilesystemConfig {
304                    mountpoints,
305                    ..Default::default()
306                },
307                ..Default::default()
308            })
309            .filesystem_metrics(&mut buffer)
310            .await;
311            buffer.metrics
312        })
313        .await;
314    }
315}