1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
//! Installer metrics for download-versus-build outcomes and install duration.
//!
//! This module records local, aggregate metrics for successful installer runs.
//! Metrics are stored in Whitaker's data directory at:
//! `<data_dir>/metrics/install_metrics.json`.
use crate::dirs::BaseDirs;
use fs2::FileExt;
use serde::{Deserialize, Serialize};
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
const METRICS_DIRNAME: &str = "metrics";
const METRICS_FILENAME: &str = "install_metrics.json";
#[path = "install_metrics_error.rs"]
mod error;
pub use error::InstallMetricsError;
/// Terminal installation path used for metrics accounting.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallMode {
/// The install succeeded via prebuilt artefact download.
Download,
/// The install succeeded via local build and staging.
Build,
}
/// Aggregate installer metrics stored on disk.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct InstallMetrics {
total_installs: u64,
download_installs: u64,
build_installs: u64,
total_install_millis: u64,
}
impl InstallMetrics {
/// Returns the number of successful installs.
///
/// # Examples
///
/// ```
/// use std::time::Duration;
/// use whitaker_installer::install_metrics::{InstallMetrics, InstallMode};
///
/// let mut metrics = InstallMetrics::default();
/// metrics.record_install(InstallMode::Download, Duration::from_millis(250));
/// assert_eq!(metrics.total_installs(), 1);
/// ```
#[must_use]
pub fn total_installs(&self) -> u64 {
self.total_installs
}
/// Returns the number of successful prebuilt-download installs.
///
/// # Examples
///
/// ```
/// use std::time::Duration;
/// use whitaker_installer::install_metrics::{InstallMetrics, InstallMode};
///
/// let mut metrics = InstallMetrics::default();
/// metrics.record_install(InstallMode::Download, Duration::from_millis(250));
/// assert_eq!(metrics.download_installs(), 1);
/// ```
#[must_use]
pub fn download_installs(&self) -> u64 {
self.download_installs
}
/// Returns the number of successful local-build installs.
///
/// # Examples
///
/// ```
/// use std::time::Duration;
/// use whitaker_installer::install_metrics::{InstallMetrics, InstallMode};
///
/// let mut metrics = InstallMetrics::default();
/// metrics.record_install(InstallMode::Build, Duration::from_millis(250));
/// assert_eq!(metrics.build_installs(), 1);
/// ```
#[must_use]
pub fn build_installs(&self) -> u64 {
self.build_installs
}
/// Returns total cumulative install duration.
///
/// # Examples
///
/// ```
/// use std::time::Duration;
/// use whitaker_installer::install_metrics::InstallMetrics;
///
/// assert_eq!(
/// InstallMetrics::default().total_install_duration(),
/// Duration::from_secs(0)
/// );
/// ```
#[must_use]
pub fn total_install_duration(&self) -> Duration {
Duration::from_millis(self.total_install_millis)
}
/// Returns `download_installs / total_installs`.
///
/// # Examples
///
/// ```
/// use whitaker_installer::install_metrics::InstallMetrics;
///
/// assert_eq!(InstallMetrics::default().download_rate(), 0.0);
/// ```
#[must_use]
pub fn download_rate(&self) -> f64 {
rate(self.download_installs, self.total_installs)
}
/// Returns `build_installs / total_installs`.
///
/// # Examples
///
/// ```
/// use whitaker_installer::install_metrics::InstallMetrics;
///
/// assert_eq!(InstallMetrics::default().build_rate(), 0.0);
/// ```
#[must_use]
pub fn build_rate(&self) -> f64 {
rate(self.build_installs, self.total_installs)
}
/// Records one successful install event.
///
/// # Examples
///
/// ```
/// use std::time::Duration;
/// use whitaker_installer::install_metrics::{InstallMetrics, InstallMode};
///
/// let mut metrics = InstallMetrics::default();
/// metrics.record_install(InstallMode::Download, Duration::from_millis(500));
/// metrics.record_install(InstallMode::Build, Duration::from_millis(1000));
/// assert_eq!(metrics.total_installs(), 2);
/// assert_eq!(metrics.download_installs(), 1);
/// assert_eq!(metrics.build_installs(), 1);
/// ```
pub fn record_install(&mut self, mode: InstallMode, duration: Duration) {
self.total_installs = self.total_installs.saturating_add(1);
match mode {
InstallMode::Download => {
self.download_installs = self.download_installs.saturating_add(1);
}
InstallMode::Build => {
self.build_installs = self.build_installs.saturating_add(1);
}
}
self.total_install_millis = self
.total_install_millis
.saturating_add(duration_to_millis(duration));
}
/// Returns a human-readable installer metrics summary line.
///
/// # Examples
///
/// ```
/// use std::time::Duration;
/// use whitaker_installer::install_metrics::{InstallMetrics, InstallMode};
///
/// let mut metrics = InstallMetrics::default();
/// metrics.record_install(InstallMode::Download, Duration::from_millis(500));
/// let summary = metrics.summary_line();
/// assert!(summary.contains("download 1/1 (100.0%)"));
/// assert!(summary.contains("total installation time 0.500s"));
/// ```
#[must_use]
pub fn summary_line(&self) -> String {
format!(
concat!(
"Install metrics: download {}/{} ({:.1}%), build {}/{} ({:.1}%), ",
"total installation time {}"
),
self.download_installs,
self.total_installs,
self.download_rate() * 100.0,
self.build_installs,
self.total_installs,
self.build_rate() * 100.0,
format_duration(self.total_install_duration()),
)
}
}
/// Outcome details returned after recording metrics.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecordOutcome {
metrics: InstallMetrics,
recovered_from_corrupt_file: bool,
}
impl RecordOutcome {
/// Returns the updated aggregate metrics.
#[must_use]
pub fn metrics(&self) -> &InstallMetrics {
&self.metrics
}
/// Returns true when a malformed metrics file was reset to defaults.
#[must_use]
pub fn recovered_from_corrupt_file(&self) -> bool {
self.recovered_from_corrupt_file
}
}
/// Records one successful install in Whitaker's metrics store.
pub fn record_install(
dirs: &dyn BaseDirs,
mode: InstallMode,
duration: Duration,
) -> Result<RecordOutcome, InstallMetricsError> {
let metrics_path = metrics_path(dirs)?;
record_install_at_path(&metrics_path, mode, duration)
}
/// Records one successful install at an explicit metrics file path.
pub fn record_install_at_path(
metrics_path: &Path,
mode: InstallMode,
duration: Duration,
) -> Result<RecordOutcome, InstallMetricsError> {
ensure_metrics_directory(metrics_path)?;
let mut metrics_file = open_metrics_file(metrics_path)?;
// Use standard-library advisory locking to serialize the read-modify-write
// cycle across concurrent installer processes.
metrics_file
.lock_exclusive()
.map_err(|source| InstallMetricsError::LockMetrics {
path: metrics_path.to_path_buf(),
source,
})?;
let (mut metrics, recovered_from_corrupt_file) = load_metrics(metrics_path, &mut metrics_file)?;
metrics.record_install(mode, duration);
persist_metrics(metrics_path, &mut metrics_file, &metrics)?;
Ok(RecordOutcome {
metrics,
recovered_from_corrupt_file,
})
}
fn metrics_path(dirs: &dyn BaseDirs) -> Result<PathBuf, InstallMetricsError> {
let data_dir = dirs
.whitaker_data_dir()
.ok_or(InstallMetricsError::MissingDataDirectory)?;
Ok(data_dir.join(METRICS_DIRNAME).join(METRICS_FILENAME))
}
fn ensure_metrics_directory(metrics_path: &Path) -> Result<(), InstallMetricsError> {
let parent = metrics_path
.parent()
.ok_or_else(|| InstallMetricsError::CreateDirectory {
path: PathBuf::new(),
source: std::io::Error::other("metrics file path has no parent"),
})?;
std::fs::create_dir_all(parent).map_err(|source| InstallMetricsError::CreateDirectory {
path: parent.to_path_buf(),
source,
})
}
fn open_metrics_file(metrics_path: &Path) -> Result<File, InstallMetricsError> {
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(metrics_path)
.map_err(|source| InstallMetricsError::ReadMetrics {
path: metrics_path.to_path_buf(),
source,
})
}
fn load_metrics(
metrics_path: &Path,
metrics_file: &mut File,
) -> Result<(InstallMetrics, bool), InstallMetricsError> {
metrics_file
.seek(SeekFrom::Start(0))
.map_err(|source| InstallMetricsError::ReadMetrics {
path: metrics_path.to_path_buf(),
source,
})?;
let mut content = String::new();
metrics_file
.read_to_string(&mut content)
.map_err(|source| InstallMetricsError::ReadMetrics {
path: metrics_path.to_path_buf(),
source,
})?;
if content.trim().is_empty() {
return Ok((InstallMetrics::default(), false));
}
match serde_json::from_str::<InstallMetrics>(&content) {
Ok(metrics) => Ok((metrics, false)),
Err(_) => Ok((InstallMetrics::default(), true)),
}
}
fn persist_metrics(
metrics_path: &Path,
metrics_file: &mut File,
metrics: &InstallMetrics,
) -> Result<(), InstallMetricsError> {
let json = serde_json::to_string_pretty(metrics)
.map_err(|source| InstallMetricsError::SerializeMetrics { source })?;
metrics_file
.set_len(0)
.and_then(|()| metrics_file.seek(SeekFrom::Start(0)).map(|_| ()))
.and_then(|()| metrics_file.write_all(json.as_bytes()))
.and_then(|()| metrics_file.sync_data())
.map_err(|source| InstallMetricsError::WriteMetrics {
path: metrics_path.to_path_buf(),
source,
})
}
fn rate(part: u64, whole: u64) -> f64 {
if whole == 0 {
0.0
} else {
part as f64 / whole as f64
}
}
fn duration_to_millis(duration: Duration) -> u64 {
match u64::try_from(duration.as_millis()) {
Ok(millis) => millis,
Err(_) => u64::MAX,
}
}
fn format_duration(duration: Duration) -> String {
let total_seconds = duration.as_secs();
let millis = duration.subsec_millis();
let hours = total_seconds / 3600;
let minutes = (total_seconds % 3600) / 60;
let seconds = total_seconds % 60;
if should_format_with_hours(hours) {
return format!("{hours}h {minutes}m {seconds}.{millis:03}s");
}
if should_format_with_minutes(minutes) {
return format!("{minutes}m {seconds}.{millis:03}s");
}
format!("{seconds}.{millis:03}s")
}
fn should_format_with_hours(hours: u64) -> bool {
hours > 0
}
fn should_format_with_minutes(minutes: u64) -> bool {
minutes > 0
}