1use std::io::Write;
24use std::path::{Path, PathBuf};
25use std::time::{SystemTime, UNIX_EPOCH};
26
27use serde::{Deserialize, Serialize};
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct Record {
32 pub ts: String,
34 pub key_id: String,
36 pub method: String,
38 pub path: String,
41 pub status: u16,
43 pub response_bytes: u64,
45 pub duration_ms: u64,
47}
48
49#[derive(Debug, Clone)]
51pub struct Metering {
52 path: PathBuf,
53}
54
55impl Metering {
56 pub fn new(path: &Path) -> Self {
58 Metering {
59 path: path.to_path_buf(),
60 }
61 }
62
63 pub fn path(&self) -> &Path {
65 &self.path
66 }
67
68 pub fn append(&self, record: &Record) {
76 let mut line = match serde_json::to_string(record) {
77 Ok(line) => line,
78 Err(err) => {
79 eprintln!("warning: cannot serialize metering record: {err}");
80 return;
81 }
82 };
83 line.push('\n');
84 match std::fs::OpenOptions::new()
85 .create(true)
86 .append(true)
87 .open(&self.path)
88 {
89 Ok(mut file) => {
90 if let Err(err) = file.write_all(line.as_bytes()) {
91 eprintln!("warning: cannot append metering record: {err}");
92 }
93 }
94 Err(err) => eprintln!(
95 "warning: cannot open metering log {}: {err}",
96 self.path.display()
97 ),
98 }
99 }
100
101 pub fn build(
103 &self,
104 key_id: &str,
105 method: &str,
106 path: &str,
107 status: u16,
108 response_bytes: u64,
109 started: SystemTime,
110 ) -> Record {
111 Record {
112 ts: now_rfc3339_millis(started),
113 key_id: key_id.to_string(),
114 method: method.to_string(),
115 path: path.to_string(),
116 status,
117 response_bytes,
118 duration_ms: started.elapsed().map(|d| d.as_millis() as u64).unwrap_or(0),
119 }
120 }
121}
122
123pub fn now_rfc3339_millis(time: SystemTime) -> String {
129 let duration = match time.duration_since(UNIX_EPOCH) {
130 Ok(duration) => duration,
131 Err(_) => return "1970-01-01T00:00:00.000Z".to_string(),
132 };
133 let secs = duration.as_secs() as i64;
134 let millis = duration.subsec_millis();
135 let days = secs.div_euclid(86_400);
136 let secs_of_day = secs.rem_euclid(86_400);
137 let (year, month, day) = civil_from_days(days);
138 format!(
139 "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{millis:03}Z",
140 secs_of_day / 3600,
141 (secs_of_day % 3600) / 60,
142 secs_of_day % 60,
143 )
144}
145
146fn civil_from_days(days: i64) -> (i64, u32, u32) {
148 let z = days + 719_468;
149 let era = z.div_euclid(146_097);
150 let doe = z.rem_euclid(146_097);
151 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
152 let y = yoe + era * 400;
153 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
154 let mp = (5 * doy + 2) / 153;
155 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
156 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
157 (if m <= 2 { y + 1 } else { y }, m, d)
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use std::time::Duration;
164
165 fn at(secs: u64, millis: u32) -> String {
167 now_rfc3339_millis(
168 UNIX_EPOCH + Duration::new(secs, 0) + Duration::from_millis(u64::from(millis)),
169 )
170 }
171
172 fn record(key_id: &str, status: u16) -> Record {
173 Record {
174 ts: "2026-09-27T04:35:12.345Z".to_string(),
175 key_id: key_id.to_string(),
176 method: "GET".to_string(),
177 path: "/v1/graphs/x/stats".to_string(),
178 status,
179 response_bytes: 12,
180 duration_ms: 7,
181 }
182 }
183
184 fn meter(dir: &tempfile::TempDir) -> Metering {
185 Metering::new(&dir.path().join("metering.log"))
186 }
187
188 #[test]
189 fn epoch_formats_as_the_epoch() {
190 assert_eq!(at(0, 0), "1970-01-01T00:00:00.000Z");
191 }
192
193 #[test]
198 fn known_dates_format_exactly() {
199 for (secs, millis, expected) in [
200 (0u64, 1u32, "1970-01-01T00:00:00.001Z"),
201 (946_684_799, 999, "1999-12-31T23:59:59.999Z"),
202 (951_782_400, 0, "2000-02-29T00:00:00.000Z"),
203 (951_868_800, 0, "2000-03-01T00:00:00.000Z"),
204 (1_790_483_712, 345, "2026-09-27T04:35:12.345Z"),
205 (1_798_761_599, 999, "2026-12-31T23:59:59.999Z"),
206 (4_107_585_600, 0, "2100-03-01T12:00:00.000Z"),
207 ] {
208 assert_eq!(at(secs, millis), expected);
209 }
210 }
211
212 #[test]
213 fn sub_second_precision_is_milliseconds() {
214 let time = UNIX_EPOCH + Duration::new(1_790_483_712, 999_999_999);
216 assert_eq!(now_rfc3339_millis(time), "2026-09-27T04:35:12.999Z");
217 }
218
219 #[test]
220 fn every_output_is_exactly_24_characters_and_zero_padded() {
221 for secs in [0u64, 59, 60, 3599, 86_399, 86_400, 1_790_483_712] {
222 let out = at(secs, 7);
223 assert_eq!(out.len(), 24, "{out}");
224 assert!(out.ends_with('Z'));
225 assert_eq!(&out[4..5], "-", "{out}");
226 assert_eq!(&out[10..11], "T", "{out}");
227 assert_eq!(&out[19..20], ".", "{out}");
228 assert!(
229 (1..=12).contains(&out[5..7].parse::<u32>().unwrap()),
230 "{out}"
231 );
232 assert!(
233 (1..=31).contains(&out[8..10].parse::<u32>().unwrap()),
234 "{out}"
235 );
236 assert!(out[11..13].parse::<u32>().unwrap() < 24, "{out}");
237 assert!(out[14..16].parse::<u32>().unwrap() < 60, "{out}");
238 assert!(out[17..19].parse::<u32>().unwrap() < 60, "{out}");
239 }
240 }
241
242 #[test]
245 fn pre_epoch_time_is_pinned_to_the_epoch() {
246 let before = UNIX_EPOCH - Duration::from_secs(1);
247 assert_eq!(now_rfc3339_millis(before), "1970-01-01T00:00:00.000Z");
248 }
249
250 #[test]
251 fn build_populates_every_field() {
252 let dir = tempfile::tempdir().unwrap();
253 let started = UNIX_EPOCH + Duration::from_secs(1_790_483_712);
254 let built = meter(&dir).build("agent", "POST", "/v1/index", 201, 42, started);
255 assert_eq!(built.ts, "2026-09-27T04:35:12.000Z");
256 assert_eq!(built.key_id, "agent");
257 assert_eq!(built.method, "POST");
258 assert_eq!(built.path, "/v1/index");
259 assert_eq!(built.status, 201);
260 assert_eq!(built.response_bytes, 42);
261 assert!(built.duration_ms > 0, "duration is measured from `started`");
264 }
265
266 #[test]
267 fn build_measures_a_non_zero_duration() {
268 let dir = tempfile::tempdir().unwrap();
269 let built = meter(&dir).build("agent", "GET", "/v1/x", 200, 0, SystemTime::now());
270 assert!(
273 built.duration_ms < 60_000,
274 "implausible duration {}",
275 built.duration_ms
276 );
277 }
278
279 #[test]
280 fn path_is_where_records_go() {
281 let dir = tempfile::tempdir().unwrap();
282 let path = dir.path().join("nested").join("metering.log");
283 assert_eq!(Metering::new(&path).path(), path);
284 }
285
286 #[test]
287 fn append_creates_the_file_and_writes_one_line() {
288 let dir = tempfile::tempdir().unwrap();
289 let meter = meter(&dir);
290 assert!(!meter.path().exists(), "file is created on first append");
291 meter.append(&record("agent", 200));
292 let contents = std::fs::read_to_string(meter.path()).unwrap();
293 assert_eq!(contents.lines().count(), 1);
294 assert!(contents.ends_with('\n'), "records are newline-delimited");
295 }
296
297 #[test]
298 fn append_accumulates_instead_of_truncating() {
299 let dir = tempfile::tempdir().unwrap();
300 let meter = meter(&dir);
301 for status in [200u16, 404, 500] {
302 meter.append(&record("agent", status));
303 }
304 let contents = std::fs::read_to_string(meter.path()).unwrap();
305 let statuses: Vec<u64> = contents
306 .lines()
307 .map(|line| {
308 serde_json::from_str::<serde_json::Value>(line).unwrap()["status"]
309 .as_u64()
310 .unwrap()
311 })
312 .collect();
313 assert_eq!(statuses, vec![200, 404, 500]);
314 }
315
316 #[test]
317 fn written_line_round_trips_to_the_record() {
318 let dir = tempfile::tempdir().unwrap();
319 let meter = meter(&dir);
320 let original = record("agent", 200);
321 meter.append(&original);
322 let line = std::fs::read_to_string(meter.path()).unwrap();
323 let parsed: Record = serde_json::from_str(line.trim()).expect("line is a record");
324 assert_eq!(parsed, original);
325 }
326
327 #[test]
330 fn line_has_exactly_the_documented_fields() {
331 let line = serde_json::to_string(&record("agent", 200)).unwrap();
332 let value: serde_json::Value = serde_json::from_str(&line).unwrap();
333 let mut fields: Vec<&str> = value
334 .as_object()
335 .unwrap()
336 .keys()
337 .map(String::as_str)
338 .collect();
339 fields.sort_unstable();
340 assert_eq!(
341 fields,
342 [
343 "duration_ms",
344 "key_id",
345 "method",
346 "path",
347 "response_bytes",
348 "status",
349 "ts"
350 ]
351 );
352 }
353
354 #[test]
357 fn query_strings_are_not_part_of_the_recorded_path() {
358 let dir = tempfile::tempdir().unwrap();
359 let built = meter(&dir).build(
360 "agent",
361 "GET",
362 "/v1/graphs/x/stats",
363 200,
364 0,
365 SystemTime::now(),
366 );
367 assert!(!built.path.contains('?'));
368 }
369
370 #[test]
374 fn unwritable_path_is_logged_not_panicked() {
375 let dir = tempfile::tempdir().unwrap();
376 let blocker = dir.path().join("blocker");
378 std::fs::write(&blocker, b"not a directory").unwrap();
379 let meter = Metering::new(&blocker.join("metering.log"));
380 meter.append(&record("agent", 200));
381 }
382
383 #[test]
387 fn append_after_truncation_writes_to_the_current_file() {
388 let dir = tempfile::tempdir().unwrap();
389 let meter = meter(&dir);
390 meter.append(&record("agent", 200));
391 std::fs::write(meter.path(), b"").unwrap();
392 meter.append(&record("agent", 404));
393 let contents = std::fs::read_to_string(meter.path()).unwrap();
394 assert_eq!(contents.lines().count(), 1);
395 }
396}