use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use crate::scheduler::Reporter;
use crate::snapshot::MetricSet;
pub struct MetricsLogReporter {
path: PathBuf,
out: BufWriter<File>,
}
impl MetricsLogReporter {
pub fn new(path: impl AsRef<Path>) -> Result<Self, String> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create metrics log dir {parent:?}: {e}"))?;
}
}
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| format!("open metrics log {path:?}: {e}"))?;
Ok(Self {
path,
out: BufWriter::new(file),
})
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl Reporter for MetricsLogReporter {
fn report(&mut self, snapshot: &MetricSet) {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
for family in snapshot.families() {
let name = family.name();
for metric in family.metrics() {
let Some(point) = metric.point() else {
continue;
};
let line = super::per_instance::render_record(
now_ms,
name,
metric.labels(),
point.value(),
);
if let Err(e) = writeln!(self.out, "{line}") {
crate::diag::warn(&format!(
"warning: metrics log write failed for {name}: {e}"
));
return;
}
}
}
if let Err(e) = self.out.flush() {
crate::diag::warn(&format!("warning: metrics log flush failed: {e}"));
}
}
fn flush(&mut self) {
if let Err(e) = self.out.flush() {
crate::diag::warn(&format!("warning: metrics log final flush failed: {e}"));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::labels::Labels;
use crate::snapshot::MetricSet;
use std::time::{Duration, Instant};
fn temp_path(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"nmbrs-metrics-log-test-{}-{}",
std::process::id(),
name
));
let _ = std::fs::remove_file(&p);
p
}
#[test]
fn writes_every_instance_to_one_file_in_tick_order() {
let path = temp_path("one-file");
let mut r = MetricsLogReporter::new(&path).unwrap();
let mut tick1 = MetricSet::new(Duration::from_secs(1));
tick1.insert_counter("ops_total", Labels::of("phase", "ann"), 42, Instant::now());
tick1.insert_counter("ops_total", Labels::of("phase", "pvs"), 17, Instant::now());
r.report(&tick1);
let mut tick2 = MetricSet::new(Duration::from_secs(1));
tick2.insert_counter("ops_total", Labels::of("phase", "ann"), 99, Instant::now());
r.report(&tick2);
r.flush();
let body = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines.len(), 3, "one record per instance per tick: {body}");
assert!(lines[0].contains("\"ops_total\""));
assert!(lines[0].contains("ann") || lines[1].contains("ann"));
assert!(lines[0].contains("pvs") || lines[1].contains("pvs"));
assert!(
lines[2].contains("99"),
"later tick appended after earlier: {body}"
);
for l in &lines {
assert!(
l.starts_with('{') && l.ends_with('}'),
"not a JSON record: {l}"
);
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn appends_rather_than_truncating() {
let path = temp_path("append");
{
let mut r = MetricsLogReporter::new(&path).unwrap();
let mut t = MetricSet::new(Duration::from_secs(1));
t.insert_counter("first", Labels::default(), 1, Instant::now());
r.report(&t);
r.flush();
}
{
let mut r = MetricsLogReporter::new(&path).unwrap();
let mut t = MetricSet::new(Duration::from_secs(1));
t.insert_counter("second", Labels::default(), 2, Instant::now());
r.report(&t);
r.flush();
}
let body = std::fs::read_to_string(&path).unwrap();
assert!(
body.contains("first"),
"earlier records must survive: {body}"
);
assert!(body.contains("second"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn creates_missing_parent_directories() {
let mut dir = std::env::temp_dir();
dir.push(format!("nmbrs-metrics-log-dir-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("nested").join("metrics.jsonl");
assert!(
MetricsLogReporter::new(&path).is_ok(),
"should create parent dirs"
);
assert!(path.exists());
let _ = std::fs::remove_dir_all(&dir);
}
}