Skip to main content

fxcp_core/
metrics.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2025 Joel Wirāmu Pauling <aenertia@aenertia.net>
3//
4// fxcp-core/src/metrics.rs  --  Prometheus metrics definitions and initialization
5
6//! Prometheus metric counters and gauges for copy operations, I/O stats,
7//! and storage detection results.
8
9#![allow(clippy::unwrap_used, clippy::expect_used)]
10
11use lazy_static::lazy_static;
12use prometheus::{
13    register_counter, register_counter_vec, register_gauge, register_gauge_vec, register_histogram_vec,
14    register_histogram, Counter, CounterVec, Gauge, GaugeVec, HistogramVec, Histogram, Registry,
15};
16use std::sync::atomic::{AtomicU64, Ordering};
17
18lazy_static! {
19    /// Shared Prometheus registry for all foxing metrics.
20    pub static ref REGISTRY: Registry = Registry::new();
21
22    /// End-to-end replication latency from source event to target write, per target.
23    pub static ref REPLICATION_LATENCY: HistogramVec = register_histogram_vec!(
24        "foxing_replication_latency_seconds",
25        "End-to-end latency from source event to target write",
26        &["target"]
27    ).unwrap();
28    /// Time spent resolving an inode number to a filesystem path.
29    pub static ref INODE_LOOKUP_DURATION: Histogram = register_histogram!(
30        "foxing_inode_lookup_duration_seconds",
31        "Time spent resolving inode to path",
32        vec![0.001, 0.01, 0.1, 1.0]
33    ).unwrap();
34
35    /// Total bytes successfully written to each replication target.
36    pub static ref BYTES_REPLICATED: CounterVec = register_counter_vec!(
37        "foxing_bytes_replicated_total",
38        "Bytes successfully written to target",
39        &["target"]
40    ).unwrap();
41    /// Total rename operations processed across all targets.
42    pub static ref RENAME_EVENTS: Counter = register_counter!(
43        "foxing_rename_events_total",
44        "Total rename operations processed"
45    ).unwrap();
46    /// Write events merged into larger chunks by the coalescer.
47    pub static ref COALESCED_WRITES: Counter = register_counter!(
48        "foxing_coalesced_writes_total",
49        "Number of write events merged into larger chunks"
50    ).unwrap();
51
52    /// Copies completed via CoW reflink (FICLONE), per target.
53    pub static ref COPY_METHOD_REFLINK: CounterVec = register_counter_vec!(
54        "foxing_copy_method_reflink_total",
55        "Writes handled via CoW Reflink",
56        &["target"]
57    ).unwrap();
58    /// Copies completed via hardware or network offload (copy_file_range/sendfile), per target.
59    pub static ref COPY_METHOD_OFFLOAD: CounterVec = register_counter_vec!(
60        "foxing_copy_method_offload_total",
61        "Writes handled via hardware/network offload",
62        &["target"]
63    ).unwrap();
64    /// Copies completed via standard read/write or io_uring, per target.
65    pub static ref COPY_METHOD_STANDARD: CounterVec = register_counter_vec!(
66        "foxing_copy_method_standard_total",
67        "Writes handled via standard read/write",
68        &["target"]
69    ).unwrap();
70    /// Atomic write attempts (tmp+rename) that fell back to buffered I/O.
71    pub static ref ATOMIC_WRITE_FALLBACKS: Counter = register_counter!(
72        "foxing_atomic_write_fallbacks_total",
73        "Number of atomic writes that failed and fell back to buffered/standard I/O"
74    ).unwrap();
75    /// Copies that fell back from io_uring to sendfile due to EBADF or timeout.
76    pub static ref COPY_METHOD_SENDFILE_FALLBACK: CounterVec = register_counter_vec!(
77        "foxing_copy_method_sendfile_fallback_total",
78        "Copies that fell back from io_uring to sendfile",
79        &["target"]
80    ).unwrap();
81}
82
83lazy_static! {
84    /// Failed attempts to create reflink version snapshots before overwrite.
85    pub static ref VERSIONING_FAILURES: Counter = register_counter!(
86        "foxing_versioning_failures_total",
87        "Failed attempts to create version snapshots"
88    ).unwrap();
89    /// Successfully created reflink version snapshots.
90    pub static ref VERSIONING_SUCCESS: Counter = register_counter!(
91        "foxing_versioning_success_total",
92        "Successfully created version snapshots"
93    ).unwrap();
94
95    /// Whether the system is currently under stress (1 = stressed, 0 = normal).
96    pub static ref GOVERNOR_STRESSED: Gauge = register_gauge!(
97        "foxing_governor_stressed",
98        "Current system stress state (1=stressed, 0=normal)"
99    ).unwrap();
100    /// Composite stress score from PSI, memory, and CPU pressure (0.0..1.0 = OK, >1.0 = critical).
101    pub static ref GOVERNOR_STRESS_SCORE: Gauge = register_gauge!(
102        "foxing_governor_stress_score",
103        "Current system stress score (0.0-1.0=OK, >1.0=Critical)"
104    ).unwrap();
105    /// Events delayed because the governor detected system pressure.
106    pub static ref GOVERNOR_THROTTLED_EVENTS: Counter = register_counter!(
107        "foxing_governor_throttled_events_total",
108        "Events delayed due to governor pressure"
109    ).unwrap();
110    /// System load averages (1-minute, 5-minute, 15-minute), labeled by period.
111    pub static ref GOVERNOR_LOAD_AVERAGE: GaugeVec = register_gauge_vec!(
112        "foxing_governor_load_average",
113        "System load average (1m, 5m, 15m)",
114        &["period"]
115    ).unwrap();
116    /// Cumulative time workers spent sleeping due to governor pacing (ms).
117    pub static ref GOVERNOR_PACING_DURATION_MS: Counter = register_counter!(
118        "foxing_governor_pacing_duration_milliseconds_total",
119        "Total time spent sleeping due to governor pacing"
120    ).unwrap();
121
122    /// Number of device-mapper layers detected beneath the target filesystem.
123    pub static ref DM_STACK_DEPTH: Gauge = register_gauge!(
124        "foxing_dm_stack_depth",
125        "Number of device-mapper layers beneath the filesystem"
126    ).unwrap();
127    /// Set to 1 when dm-crypt (LUKS) is detected in the storage stack.
128    pub static ref DM_CRYPT_DETECTED: Gauge = register_gauge!(
129        "foxing_dm_crypt_detected",
130        "1 if dm-crypt (LUKS) detected in storage stack"
131    ).unwrap();
132    /// Physical block size of the underlying storage device (bytes).
133    pub static ref STORAGE_PHYSICAL_BLOCK_SIZE: Gauge = register_gauge!(
134        "foxing_storage_physical_block_size",
135        "Physical block size of base storage device (bytes)"
136    ).unwrap();
137
138    /// Maximum configured memory budget for I/O buffers across all workers (bytes).
139    pub static ref GLOBAL_BUFFER_LIMIT: Gauge = register_gauge!(
140        "foxing_global_buffer_limit_bytes",
141        "Maximum configured memory for buffers (Bytes)"
142    ).unwrap();
143    /// Atomic counter tracking the total number of allocated buffers globally.
144    pub static ref GLOBAL_BUFFER_COUNT: AtomicU64 = AtomicU64::new(0);
145    /// Current total memory allocated for I/O buffers across all workers (bytes).
146    pub static ref GLOBAL_MEMORY_USAGE_BYTES: Gauge = register_gauge!(
147        "foxing_global_memory_usage_bytes",
148        "Current total memory allocated for IO buffers across all workers"
149    ).unwrap();
150
151    /// Number of buffers currently in the worker's buffer pool.
152    pub static ref BUFFER_POOL_CAPACITY: Gauge = register_gauge!(
153        "foxing_buffer_pool_capacity_buffers",
154        "Number of buffers in the worker's buffer pool"
155    ).unwrap();
156    /// Size of each individual buffer chunk in the pool (bytes).
157    pub static ref BUFFER_POOL_CHUNK_SIZE: Gauge = register_gauge!(
158        "foxing_buffer_pool_chunk_size_bytes",
159        "Size of each buffer chunk in bytes"
160    ).unwrap();
161    /// Total memory allocated to the buffer pool (bytes).
162    pub static ref BUFFER_POOL_TOTAL_BYTES: Gauge = register_gauge!(
163        "foxing_buffer_pool_total_bytes",
164        "Total memory allocated to buffer pool"
165    ).unwrap();
166}
167
168lazy_static! {
169    /// `.foxing_meta` sidecar files created (fallback for filesystems without xattr support).
170    pub static ref SIDECAR_FILES_CREATED: Counter = register_counter!(
171        "foxing_sidecar_files_created_total",
172        "Number of .foxing_meta files created (fallback metadata)"
173    ).unwrap();
174
175    /// BLAKE3 hash verifications performed (post-copy integrity checks).
176    pub static ref HASH_VERIFICATIONS_TOTAL: Counter = register_counter!(
177        "foxing_hash_verifications_total",
178        "Number of BLAKE3 hash verifications performed"
179    ).unwrap();
180    /// Files skipped because their cached signature matched the source.
181    pub static ref HASH_CACHE_HITS: Counter = register_counter!(
182        "foxing_hash_cache_hits_total",
183        "Files skipped due to matching signature cache"
184    ).unwrap();
185    /// NFS bypass fallbacks due to Kerberos auth (krb5/krb5i/krb5p) on the mount.
186    pub static ref NFS_BYPASS_KRB5_FALLBACK: Counter = register_counter!(
187        "foxing_nfs_bypass_krb5_fallback_total",
188        "NFS bypass unavailable due to Kerberos auth  --  fell back to kernel VFS path"
189    ).unwrap();
190    /// NFS bypass TLS upgrade skipped  --  xprtsec=tls mount but tls feature disabled or STARTTLS rejected.
191    pub static ref NFS_BYPASS_TLS_FALLBACK: Counter = register_counter!(
192        "foxing_nfs_bypass_tls_fallback_total",
193        "NFS bypass TLS upgrade skipped  --  xprtsec=tls mount but tls feature disabled or STARTTLS rejected"
194    ).unwrap();
195    /// Double encryption detected: sec=krb5p (per-RPC AES) + xprtsec=tls.
196    pub static ref NFS_BYPASS_DOUBLE_ENCRYPTION: Counter = register_counter!(
197        "foxing_nfs_bypass_double_encryption_total",
198        "sec=krb5p + xprtsec=tls detected  --  redundant double encryption"
199    ).unwrap();
200    /// kTLS kernel offload successfully promoted after TLS handshake.
201    pub static ref NFS_BYPASS_KTLS_PROMOTED: Counter = register_counter!(
202        "foxing_nfs_bypass_ktls_promoted_total",
203        "kTLS kernel offload promoted after userspace TLS handshake"
204    ).unwrap();
205
206    /// Time spent computing BLAKE3 hashes (seconds).
207    pub static ref HASH_COMPUTATION_DURATION: Histogram = register_histogram!(
208        "foxing_hash_computation_seconds",
209        "Time spent computing BLAKE3 hashes"
210    ).unwrap();
211}
212
213/// Set the global buffer memory limit gauge from a megabyte value.
214pub fn initialize_metrics(global_limit_mb: u64) {
215    GLOBAL_BUFFER_LIMIT.set((global_limit_mb * 1024 * 1024) as f64);
216}
217
218/// Batches increments to a Prometheus [`Counter`] to reduce atomic contention.
219///
220/// Accumulates increments locally and flushes to the underlying counter
221/// when the batch threshold is reached or on drop.
222pub struct BatchedCounter {
223    counter: Counter,
224    local_count: u64,
225    batch_size: u64,
226}
227
228impl BatchedCounter {
229    pub fn new(counter: Counter, batch_size: u64) -> Self {
230        Self {
231            counter,
232            local_count: 0,
233            batch_size,
234        }
235    }
236
237    #[inline]
238    pub fn inc(&mut self) {
239        self.local_count += 1;
240        if self.local_count >= self.batch_size {
241            self.flush();
242        }
243    }
244
245    #[inline]
246    pub fn inc_by(&mut self, val: u64) {
247        self.local_count += val;
248        if self.local_count >= self.batch_size {
249            self.flush();
250        }
251    }
252
253    #[inline]
254    pub fn flush(&mut self) {
255        if self.local_count > 0 {
256            self.counter.inc_by(self.local_count as f64);
257            self.local_count = 0;
258        }
259    }
260}
261
262impl Drop for BatchedCounter {
263    fn drop(&mut self) {
264        self.flush();
265    }
266}
267
268/// Batches increments and decrements to a global [`AtomicU64`] to reduce contention.
269///
270/// Flushes the local delta when the batch threshold is reached or on drop.
271pub struct BatchedAtomicCounter {
272    global: &'static AtomicU64,
273    local_count: i64,
274    batch_size: i64,
275}
276
277impl BatchedAtomicCounter {
278    pub fn new(global: &'static AtomicU64, batch_size: i64) -> Self {
279        Self {
280            global,
281            local_count: 0,
282            batch_size,
283        }
284    }
285
286    #[inline]
287    pub fn inc(&mut self) {
288        self.local_count += 1;
289        if self.local_count >= self.batch_size {
290            self.flush();
291        }
292    }
293
294    #[inline]
295    pub fn dec(&mut self) {
296        self.local_count -= 1;
297        if self.local_count <= -self.batch_size {
298            self.flush();
299        }
300    }
301
302    #[inline]
303    pub fn flush(&mut self) {
304        if self.local_count != 0 {
305            if self.local_count > 0 {
306                self.global.fetch_add(self.local_count as u64, Ordering::Relaxed);
307            } else {
308                self.global.fetch_sub((-self.local_count) as u64, Ordering::Relaxed);
309            }
310            self.local_count = 0;
311        }
312    }
313}
314
315impl Drop for BatchedAtomicCounter {
316    fn drop(&mut self) {
317        self.flush();
318    }
319}
320
321lazy_static! {
322    /// RDMA per-RPC round-trip latency in microseconds (Send WR post -> CQ completion).
323    pub static ref RDMA_RPC_LATENCY_US: Histogram = register_histogram!(
324        "foxing_rdma_rpc_latency_us",
325        "RDMA per-RPC round-trip latency in microseconds",
326        vec![1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0, 500.0, 1000.0]
327    ).expect("failed to register foxing_rdma_rpc_latency_us");
328}