use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Record {
pub ts: String,
pub key_id: String,
pub method: String,
pub path: String,
pub status: u16,
pub response_bytes: u64,
pub duration_ms: u64,
}
#[derive(Debug, Clone)]
pub struct Metering {
path: PathBuf,
}
impl Metering {
pub fn new(path: &Path) -> Self {
Metering {
path: path.to_path_buf(),
}
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn append(&self, record: &Record) {
let mut line = match serde_json::to_string(record) {
Ok(line) => line,
Err(err) => {
eprintln!("warning: cannot serialize metering record: {err}");
return;
}
};
line.push('\n');
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
{
Ok(mut file) => {
if let Err(err) = file.write_all(line.as_bytes()) {
eprintln!("warning: cannot append metering record: {err}");
}
}
Err(err) => eprintln!(
"warning: cannot open metering log {}: {err}",
self.path.display()
),
}
}
pub fn build(
&self,
key_id: &str,
method: &str,
path: &str,
status: u16,
response_bytes: u64,
started: SystemTime,
) -> Record {
Record {
ts: now_rfc3339_millis(started),
key_id: key_id.to_string(),
method: method.to_string(),
path: path.to_string(),
status,
response_bytes,
duration_ms: started.elapsed().map(|d| d.as_millis() as u64).unwrap_or(0),
}
}
}
pub fn now_rfc3339_millis(time: SystemTime) -> String {
let duration = match time.duration_since(UNIX_EPOCH) {
Ok(duration) => duration,
Err(_) => return "1970-01-01T00:00:00.000Z".to_string(),
};
let secs = duration.as_secs() as i64;
let millis = duration.subsec_millis();
let days = secs.div_euclid(86_400);
let secs_of_day = secs.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days);
format!(
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{millis:03}Z",
secs_of_day / 3600,
(secs_of_day % 3600) / 60,
secs_of_day % 60,
)
}
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn at(secs: u64, millis: u32) -> String {
now_rfc3339_millis(
UNIX_EPOCH + Duration::new(secs, 0) + Duration::from_millis(u64::from(millis)),
)
}
fn record(key_id: &str, status: u16) -> Record {
Record {
ts: "2026-09-27T04:35:12.345Z".to_string(),
key_id: key_id.to_string(),
method: "GET".to_string(),
path: "/v1/graphs/x/stats".to_string(),
status,
response_bytes: 12,
duration_ms: 7,
}
}
fn meter(dir: &tempfile::TempDir) -> Metering {
Metering::new(&dir.path().join("metering.log"))
}
#[test]
fn epoch_formats_as_the_epoch() {
assert_eq!(at(0, 0), "1970-01-01T00:00:00.000Z");
}
#[test]
fn known_dates_format_exactly() {
for (secs, millis, expected) in [
(0u64, 1u32, "1970-01-01T00:00:00.001Z"),
(946_684_799, 999, "1999-12-31T23:59:59.999Z"),
(951_782_400, 0, "2000-02-29T00:00:00.000Z"),
(951_868_800, 0, "2000-03-01T00:00:00.000Z"),
(1_790_483_712, 345, "2026-09-27T04:35:12.345Z"),
(1_798_761_599, 999, "2026-12-31T23:59:59.999Z"),
(4_107_585_600, 0, "2100-03-01T12:00:00.000Z"),
] {
assert_eq!(at(secs, millis), expected);
}
}
#[test]
fn sub_second_precision_is_milliseconds() {
let time = UNIX_EPOCH + Duration::new(1_790_483_712, 999_999_999);
assert_eq!(now_rfc3339_millis(time), "2026-09-27T04:35:12.999Z");
}
#[test]
fn every_output_is_exactly_24_characters_and_zero_padded() {
for secs in [0u64, 59, 60, 3599, 86_399, 86_400, 1_790_483_712] {
let out = at(secs, 7);
assert_eq!(out.len(), 24, "{out}");
assert!(out.ends_with('Z'));
assert_eq!(&out[4..5], "-", "{out}");
assert_eq!(&out[10..11], "T", "{out}");
assert_eq!(&out[19..20], ".", "{out}");
assert!(
(1..=12).contains(&out[5..7].parse::<u32>().unwrap()),
"{out}"
);
assert!(
(1..=31).contains(&out[8..10].parse::<u32>().unwrap()),
"{out}"
);
assert!(out[11..13].parse::<u32>().unwrap() < 24, "{out}");
assert!(out[14..16].parse::<u32>().unwrap() < 60, "{out}");
assert!(out[17..19].parse::<u32>().unwrap() < 60, "{out}");
}
}
#[test]
fn pre_epoch_time_is_pinned_to_the_epoch() {
let before = UNIX_EPOCH - Duration::from_secs(1);
assert_eq!(now_rfc3339_millis(before), "1970-01-01T00:00:00.000Z");
}
#[test]
fn build_populates_every_field() {
let dir = tempfile::tempdir().unwrap();
let started = UNIX_EPOCH + Duration::from_secs(1_790_483_712);
let built = meter(&dir).build("agent", "POST", "/v1/index", 201, 42, started);
assert_eq!(built.ts, "2026-09-27T04:35:12.000Z");
assert_eq!(built.key_id, "agent");
assert_eq!(built.method, "POST");
assert_eq!(built.path, "/v1/index");
assert_eq!(built.status, 201);
assert_eq!(built.response_bytes, 42);
assert!(built.duration_ms > 0, "duration is measured from `started`");
}
#[test]
fn build_measures_a_non_zero_duration() {
let dir = tempfile::tempdir().unwrap();
let built = meter(&dir).build("agent", "GET", "/v1/x", 200, 0, SystemTime::now());
assert!(
built.duration_ms < 60_000,
"implausible duration {}",
built.duration_ms
);
}
#[test]
fn path_is_where_records_go() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("metering.log");
assert_eq!(Metering::new(&path).path(), path);
}
#[test]
fn append_creates_the_file_and_writes_one_line() {
let dir = tempfile::tempdir().unwrap();
let meter = meter(&dir);
assert!(!meter.path().exists(), "file is created on first append");
meter.append(&record("agent", 200));
let contents = std::fs::read_to_string(meter.path()).unwrap();
assert_eq!(contents.lines().count(), 1);
assert!(contents.ends_with('\n'), "records are newline-delimited");
}
#[test]
fn append_accumulates_instead_of_truncating() {
let dir = tempfile::tempdir().unwrap();
let meter = meter(&dir);
for status in [200u16, 404, 500] {
meter.append(&record("agent", status));
}
let contents = std::fs::read_to_string(meter.path()).unwrap();
let statuses: Vec<u64> = contents
.lines()
.map(|line| {
serde_json::from_str::<serde_json::Value>(line).unwrap()["status"]
.as_u64()
.unwrap()
})
.collect();
assert_eq!(statuses, vec![200, 404, 500]);
}
#[test]
fn written_line_round_trips_to_the_record() {
let dir = tempfile::tempdir().unwrap();
let meter = meter(&dir);
let original = record("agent", 200);
meter.append(&original);
let line = std::fs::read_to_string(meter.path()).unwrap();
let parsed: Record = serde_json::from_str(line.trim()).expect("line is a record");
assert_eq!(parsed, original);
}
#[test]
fn line_has_exactly_the_documented_fields() {
let line = serde_json::to_string(&record("agent", 200)).unwrap();
let value: serde_json::Value = serde_json::from_str(&line).unwrap();
let mut fields: Vec<&str> = value
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
fields.sort_unstable();
assert_eq!(
fields,
[
"duration_ms",
"key_id",
"method",
"path",
"response_bytes",
"status",
"ts"
]
);
}
#[test]
fn query_strings_are_not_part_of_the_recorded_path() {
let dir = tempfile::tempdir().unwrap();
let built = meter(&dir).build(
"agent",
"GET",
"/v1/graphs/x/stats",
200,
0,
SystemTime::now(),
);
assert!(!built.path.contains('?'));
}
#[test]
fn unwritable_path_is_logged_not_panicked() {
let dir = tempfile::tempdir().unwrap();
let blocker = dir.path().join("blocker");
std::fs::write(&blocker, b"not a directory").unwrap();
let meter = Metering::new(&blocker.join("metering.log"));
meter.append(&record("agent", 200));
}
#[test]
fn append_after_truncation_writes_to_the_current_file() {
let dir = tempfile::tempdir().unwrap();
let meter = meter(&dir);
meter.append(&record("agent", 200));
std::fs::write(meter.path(), b"").unwrap();
meter.append(&record("agent", 404));
let contents = std::fs::read_to_string(meter.path()).unwrap();
assert_eq!(contents.lines().count(), 1);
}
}