Skip to main content

syncweb_core/
stats.rs

1use std::{
2    collections::BTreeMap,
3    path::{Path, PathBuf},
4    time::{SystemTime, UNIX_EPOCH},
5};
6
7use serde::{Deserialize, Serialize};
8
9use crate::{Result, SyncwebError};
10
11/// Transfer counters for one synchronized folder.
12#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
13#[non_exhaustive]
14pub struct FolderStats {
15    pub upload: u64,
16    pub download: u64,
17    pub files_transferred: u64,
18}
19
20/// Transfer counters for one peer.
21#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
22#[non_exhaustive]
23pub struct PeerStats {
24    pub upload: u64,
25    pub download: u64,
26    pub connection_count: u64,
27}
28
29/// Persisted bandwidth accounting for the local node.
30#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
31#[non_exhaustive]
32pub struct BandwidthStats {
33    pub total_upload: u64,
34    pub total_download: u64,
35    pub per_folder: BTreeMap<String, FolderStats>,
36    pub per_peer: BTreeMap<String, PeerStats>,
37    pub period_start: u64,
38}
39
40impl Default for BandwidthStats {
41    fn default() -> Self {
42        Self {
43            total_upload: 0,
44            total_download: 0,
45            per_folder: BTreeMap::new(),
46            per_peer: BTreeMap::new(),
47            period_start: now_seconds(),
48        }
49    }
50}
51
52impl BandwidthStats {
53    /// Load counters from JSON, returning empty counters for a missing file.
54    ///
55    /// # Errors
56    ///
57    /// Returns an error if the counters cannot be read or decoded.
58    pub fn load(stats_path_impl: impl AsRef<Path>) -> Result<Self> {
59        let stats_path = stats_path_impl.as_ref();
60        if !stats_path.exists() {
61            return Ok(Self::default());
62        }
63        let bytes = std::fs::read(stats_path)
64            .map_err(|error| SyncwebError::operation("failed to read bandwidth stats", error))?;
65        serde_json::from_slice(&bytes)
66            .map_err(|error| SyncwebError::operation("failed to parse bandwidth stats", error))
67    }
68
69    /// Persist counters atomically.
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if the counters cannot be serialized or persisted.
74    pub fn save(&self, stats_path_impl: impl AsRef<Path>) -> Result<()> {
75        let stats_path = stats_path_impl.as_ref();
76        if let Some(parent) = stats_path.parent()
77            && !parent.as_os_str().is_empty()
78        {
79            std::fs::create_dir_all(parent)
80                .map_err(|error| SyncwebError::operation("failed to create stats directory", error))?;
81        }
82        let temporary = temporary_path(stats_path);
83        let result = (|| -> Result<()> {
84            let bytes = serde_json::to_vec_pretty(self)
85                .map_err(|error| SyncwebError::operation("failed to serialize bandwidth stats", error))?;
86            std::fs::write(&temporary, bytes)
87                .map_err(|error| SyncwebError::operation("failed to write temporary bandwidth stats", error))?;
88            std::fs::rename(&temporary, stats_path)
89                .map_err(|error| SyncwebError::operation("failed to persist bandwidth stats", error))
90        })();
91        if result.is_err()
92            && let Err(error) = std::fs::remove_file(&temporary)
93        {
94            tracing::warn!(
95                path = %temporary.display(),
96                ?error,
97                "failed to clean up temporary bandwidth stats"
98            );
99        }
100        result
101    }
102
103    /// Reset all counters while retaining the same storage object.
104    pub fn reset(&mut self) {
105        *self = Self::default();
106    }
107
108    /// Record downloaded bytes, optionally scoped to a folder and peer.
109    pub fn record_download(
110        &mut self,
111        bytes: u64,
112        files: u64,
113        folder_name_option: Option<&str>,
114        peer_id_option: Option<&str>,
115    ) {
116        self.total_download = self.total_download.saturating_add(bytes);
117        if let Some(folder_name) = folder_name_option {
118            let stats = self.per_folder.entry(folder_name.to_owned()).or_default();
119            stats.download = stats.download.saturating_add(bytes);
120            stats.files_transferred = stats.files_transferred.saturating_add(files);
121        }
122        if let Some(peer_id) = peer_id_option {
123            let stats = self.per_peer.entry(peer_id.to_owned()).or_default();
124            stats.download = stats.download.saturating_add(bytes);
125        }
126    }
127
128    /// Record uploaded bytes, optionally scoped to a folder and peer.
129    pub fn record_upload(
130        &mut self,
131        bytes: u64,
132        files: u64,
133        folder_name_option: Option<&str>,
134        peer_id_option: Option<&str>,
135    ) {
136        self.total_upload = self.total_upload.saturating_add(bytes);
137        if let Some(folder_name) = folder_name_option {
138            let stats = self.per_folder.entry(folder_name.to_owned()).or_default();
139            stats.upload = stats.upload.saturating_add(bytes);
140            stats.files_transferred = stats.files_transferred.saturating_add(files);
141        }
142        if let Some(peer_id) = peer_id_option {
143            let stats = self.per_peer.entry(peer_id.to_owned()).or_default();
144            stats.upload = stats.upload.saturating_add(bytes);
145        }
146    }
147
148    /// Record a newly observed peer connection.
149    pub fn record_connection(&mut self, peer: &str) {
150        let stats = self.per_peer.entry(peer.to_owned()).or_default();
151        stats.connection_count = stats.connection_count.saturating_add(1);
152    }
153}
154
155fn now_seconds() -> u64 {
156    SystemTime::now()
157        .duration_since(UNIX_EPOCH)
158        .map_or(0, |duration| duration.as_secs())
159}
160
161fn temporary_path(path: &Path) -> PathBuf {
162    path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()))
163}