1use std::io::Write;
35use std::path::{Path, PathBuf};
36use std::time::{SystemTime, UNIX_EPOCH};
37
38use serde::{Deserialize, Serialize};
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct Record {
43 pub ts: String,
45 pub key_id: String,
47 #[serde(default)]
51 pub plan: String,
52 pub method: String,
54 pub path: String,
57 pub status: u16,
59 pub response_bytes: u64,
61 pub duration_ms: u64,
63}
64
65#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
71pub struct MonthUsage {
72 pub queries: u64,
74 pub index_ops: u64,
76}
77
78pub fn current_month() -> String {
82 now_rfc3339_millis(SystemTime::now())[..7].to_string()
83}
84
85#[derive(Debug, Clone)]
87pub struct Metering {
88 path: PathBuf,
89}
90
91impl Metering {
92 pub fn new(path: &Path) -> Self {
94 Metering {
95 path: path.to_path_buf(),
96 }
97 }
98
99 pub fn path(&self) -> &Path {
101 &self.path
102 }
103
104 pub fn append(&self, record: &Record) {
112 let mut line = match serde_json::to_string(record) {
113 Ok(line) => line,
114 Err(err) => {
115 eprintln!("warning: cannot serialize metering record: {err}");
116 return;
117 }
118 };
119 line.push('\n');
120 match std::fs::OpenOptions::new()
121 .create(true)
122 .append(true)
123 .open(&self.path)
124 {
125 Ok(mut file) => {
126 if let Err(err) = file.write_all(line.as_bytes()) {
127 eprintln!("warning: cannot append metering record: {err}");
128 }
129 }
130 Err(err) => eprintln!(
131 "warning: cannot open metering log {}: {err}",
132 self.path.display()
133 ),
134 }
135 }
136
137 pub fn usage_in_month(&self, key_id: &str, month: &str) -> MonthUsage {
146 let mut usage = MonthUsage::default();
147 self.scan_month(key_id, month, None, &mut usage);
148 usage
149 }
150
151 pub fn query_count_reaches(&self, key_id: &str, month: &str, limit: u64) -> bool {
157 let mut usage = MonthUsage::default();
158 self.scan_month(key_id, month, Some(limit), &mut usage);
159 usage.queries >= limit
160 }
161
162 fn scan_month(
166 &self,
167 key_id: &str,
168 month: &str,
169 stop_at_queries: Option<u64>,
170 usage: &mut MonthUsage,
171 ) {
172 let Ok(contents) = std::fs::read_to_string(&self.path) else {
173 return;
174 };
175 for line in contents.lines() {
176 let Ok(record) = serde_json::from_str::<Record>(line) else {
177 continue;
178 };
179 if record.key_id != key_id || !record.ts.starts_with(month) {
180 continue;
181 }
182 if record.method == "POST" && record.path == "/v1/index" {
183 usage.index_ops += 1;
184 } else {
185 usage.queries += 1;
186 if stop_at_queries.is_some_and(|stop| usage.queries >= stop) {
187 return;
188 }
189 }
190 }
191 }
192
193 #[allow(clippy::too_many_arguments)]
198 pub fn build(
199 &self,
200 key_id: &str,
201 plan: &str,
202 method: &str,
203 path: &str,
204 status: u16,
205 response_bytes: u64,
206 started: SystemTime,
207 ) -> Record {
208 Record {
209 ts: now_rfc3339_millis(started),
210 key_id: key_id.to_string(),
211 plan: plan.to_string(),
212 method: method.to_string(),
213 path: path.to_string(),
214 status,
215 response_bytes,
216 duration_ms: started.elapsed().map(|d| d.as_millis() as u64).unwrap_or(0),
217 }
218 }
219}
220
221pub fn now_rfc3339_millis(time: SystemTime) -> String {
227 let duration = match time.duration_since(UNIX_EPOCH) {
228 Ok(duration) => duration,
229 Err(_) => return "1970-01-01T00:00:00.000Z".to_string(),
230 };
231 let secs = duration.as_secs() as i64;
232 let millis = duration.subsec_millis();
233 let days = secs.div_euclid(86_400);
234 let secs_of_day = secs.rem_euclid(86_400);
235 let (year, month, day) = civil_from_days(days);
236 format!(
237 "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{millis:03}Z",
238 secs_of_day / 3600,
239 (secs_of_day % 3600) / 60,
240 secs_of_day % 60,
241 )
242}
243
244fn civil_from_days(days: i64) -> (i64, u32, u32) {
246 let z = days + 719_468;
247 let era = z.div_euclid(146_097);
248 let doe = z.rem_euclid(146_097);
249 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
250 let y = yoe + era * 400;
251 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
252 let mp = (5 * doy + 2) / 153;
253 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
254 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
255 (if m <= 2 { y + 1 } else { y }, m, d)
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use std::time::Duration;
262
263 fn at(secs: u64, millis: u32) -> String {
265 now_rfc3339_millis(
266 UNIX_EPOCH + Duration::new(secs, 0) + Duration::from_millis(u64::from(millis)),
267 )
268 }
269
270 fn record(key_id: &str, status: u16) -> Record {
271 Record {
272 ts: "2026-09-27T04:35:12.345Z".to_string(),
273 key_id: key_id.to_string(),
274 plan: "self-hosted".to_string(),
275 method: "GET".to_string(),
276 path: "/v1/graphs/x/stats".to_string(),
277 status,
278 response_bytes: 12,
279 duration_ms: 7,
280 }
281 }
282
283 fn meter(dir: &tempfile::TempDir) -> Metering {
284 Metering::new(&dir.path().join("metering.log"))
285 }
286
287 #[test]
288 fn epoch_formats_as_the_epoch() {
289 assert_eq!(at(0, 0), "1970-01-01T00:00:00.000Z");
290 }
291
292 #[test]
297 fn known_dates_format_exactly() {
298 for (secs, millis, expected) in [
299 (0u64, 1u32, "1970-01-01T00:00:00.001Z"),
300 (946_684_799, 999, "1999-12-31T23:59:59.999Z"),
301 (951_782_400, 0, "2000-02-29T00:00:00.000Z"),
302 (951_868_800, 0, "2000-03-01T00:00:00.000Z"),
303 (1_790_483_712, 345, "2026-09-27T04:35:12.345Z"),
304 (1_798_761_599, 999, "2026-12-31T23:59:59.999Z"),
305 (4_107_585_600, 0, "2100-03-01T12:00:00.000Z"),
306 ] {
307 assert_eq!(at(secs, millis), expected);
308 }
309 }
310
311 #[test]
312 fn sub_second_precision_is_milliseconds() {
313 let time = UNIX_EPOCH + Duration::new(1_790_483_712, 999_999_999);
315 assert_eq!(now_rfc3339_millis(time), "2026-09-27T04:35:12.999Z");
316 }
317
318 #[test]
319 fn every_output_is_exactly_24_characters_and_zero_padded() {
320 for secs in [0u64, 59, 60, 3599, 86_399, 86_400, 1_790_483_712] {
321 let out = at(secs, 7);
322 assert_eq!(out.len(), 24, "{out}");
323 assert!(out.ends_with('Z'));
324 assert_eq!(&out[4..5], "-", "{out}");
325 assert_eq!(&out[10..11], "T", "{out}");
326 assert_eq!(&out[19..20], ".", "{out}");
327 assert!(
328 (1..=12).contains(&out[5..7].parse::<u32>().unwrap()),
329 "{out}"
330 );
331 assert!(
332 (1..=31).contains(&out[8..10].parse::<u32>().unwrap()),
333 "{out}"
334 );
335 assert!(out[11..13].parse::<u32>().unwrap() < 24, "{out}");
336 assert!(out[14..16].parse::<u32>().unwrap() < 60, "{out}");
337 assert!(out[17..19].parse::<u32>().unwrap() < 60, "{out}");
338 }
339 }
340
341 #[test]
344 fn pre_epoch_time_is_pinned_to_the_epoch() {
345 let before = UNIX_EPOCH - Duration::from_secs(1);
346 assert_eq!(now_rfc3339_millis(before), "1970-01-01T00:00:00.000Z");
347 }
348
349 #[test]
350 fn build_populates_every_field() {
351 let dir = tempfile::tempdir().unwrap();
352 let started = UNIX_EPOCH + Duration::from_secs(1_790_483_712);
353 let built = meter(&dir).build("agent", "hosted", "POST", "/v1/index", 201, 42, started);
354 assert_eq!(built.ts, "2026-09-27T04:35:12.000Z");
355 assert_eq!(built.key_id, "agent");
356 assert_eq!(built.plan, "hosted");
357 assert_eq!(built.method, "POST");
358 assert_eq!(built.path, "/v1/index");
359 assert_eq!(built.status, 201);
360 assert_eq!(built.response_bytes, 42);
361 assert!(built.duration_ms > 0, "duration is measured from `started`");
364 }
365
366 #[test]
367 fn build_measures_a_non_zero_duration() {
368 let dir = tempfile::tempdir().unwrap();
369 let built = meter(&dir).build("agent", "hosted", "GET", "/v1/x", 200, 0, SystemTime::now());
370 assert!(
373 built.duration_ms < 60_000,
374 "implausible duration {}",
375 built.duration_ms
376 );
377 }
378
379 #[test]
380 fn path_is_where_records_go() {
381 let dir = tempfile::tempdir().unwrap();
382 let path = dir.path().join("nested").join("metering.log");
383 assert_eq!(Metering::new(&path).path(), path);
384 }
385
386 #[test]
387 fn append_creates_the_file_and_writes_one_line() {
388 let dir = tempfile::tempdir().unwrap();
389 let meter = meter(&dir);
390 assert!(!meter.path().exists(), "file is created on first append");
391 meter.append(&record("agent", 200));
392 let contents = std::fs::read_to_string(meter.path()).unwrap();
393 assert_eq!(contents.lines().count(), 1);
394 assert!(contents.ends_with('\n'), "records are newline-delimited");
395 }
396
397 #[test]
398 fn append_accumulates_instead_of_truncating() {
399 let dir = tempfile::tempdir().unwrap();
400 let meter = meter(&dir);
401 for status in [200u16, 404, 500] {
402 meter.append(&record("agent", status));
403 }
404 let contents = std::fs::read_to_string(meter.path()).unwrap();
405 let statuses: Vec<u64> = contents
406 .lines()
407 .map(|line| {
408 serde_json::from_str::<serde_json::Value>(line).unwrap()["status"]
409 .as_u64()
410 .unwrap()
411 })
412 .collect();
413 assert_eq!(statuses, vec![200, 404, 500]);
414 }
415
416 #[test]
417 fn written_line_round_trips_to_the_record() {
418 let dir = tempfile::tempdir().unwrap();
419 let meter = meter(&dir);
420 let original = record("agent", 200);
421 meter.append(&original);
422 let line = std::fs::read_to_string(meter.path()).unwrap();
423 let parsed: Record = serde_json::from_str(line.trim()).expect("line is a record");
424 assert_eq!(parsed, original);
425 }
426
427 #[test]
432 fn line_has_exactly_the_documented_fields() {
433 let line = serde_json::to_string(&record("agent", 200)).unwrap();
434 let value: serde_json::Value = serde_json::from_str(&line).unwrap();
435 let mut fields: Vec<&str> = value
436 .as_object()
437 .unwrap()
438 .keys()
439 .map(String::as_str)
440 .collect();
441 fields.sort_unstable();
442 assert_eq!(
443 fields,
444 [
445 "duration_ms",
446 "key_id",
447 "method",
448 "path",
449 "plan",
450 "response_bytes",
451 "status",
452 "ts"
453 ]
454 );
455 }
456
457 #[test]
461 fn lines_without_a_plan_still_parse() {
462 let legacy = r#"{"ts":"2026-09-27T04:35:12.345Z","key_id":"agent","method":"GET","path":"/v1/graphs/x/stats","status":200,"response_bytes":12,"duration_ms":7}"#;
463 let parsed: Record = serde_json::from_str(legacy).expect("legacy line parses");
464 assert_eq!(parsed.plan, "");
465 assert_eq!(parsed.key_id, "agent");
466 }
467
468 #[test]
474 fn monthly_counters_are_readable_from_the_log() {
475 let dir = tempfile::tempdir().unwrap();
476 let meter = meter(&dir);
477 let september = UNIX_EPOCH + Duration::from_secs(1_788_595_200);
479 let october = UNIX_EPOCH + Duration::from_secs(1_791_187_200);
480 for (started, method, path) in [
481 (september, "POST", "/v1/index"),
482 (september, "GET", "/v1/graphs/x/stats"),
483 (september, "GET", "/v1/graphs/x/verify"),
484 (october, "GET", "/v1/graphs/x/stats"),
485 ] {
486 meter.append(&meter.build("agent", "hosted", method, path, 200, 1, started));
487 }
488
489 let contents = std::fs::read_to_string(meter.path()).unwrap();
490 let mut september_queries = 0;
491 let mut september_indexes = 0;
492 let mut october_queries = 0;
493 for line in contents.lines() {
494 let record: Record = serde_json::from_str(line).expect("line parses");
495 assert_eq!(record.plan, "hosted");
496 match (&record.ts[..7], record.method.as_str(), record.path.as_str()) {
497 ("2026-09", "POST", "/v1/index") => september_indexes += 1,
498 ("2026-09", _, _) => september_queries += 1,
499 ("2026-10", _, _) => october_queries += 1,
500 other => panic!("unexpected record month: {other:?}"),
501 }
502 }
503 assert_eq!(september_indexes, 1);
504 assert_eq!(september_queries, 2);
505 assert_eq!(october_queries, 1);
506 }
507
508 #[test]
513 fn usage_in_month_agrees_with_the_monthly_counters() {
514 let dir = tempfile::tempdir().unwrap();
515 let meter = meter(&dir);
516 let september = UNIX_EPOCH + Duration::from_secs(1_788_595_200);
517 let october = UNIX_EPOCH + Duration::from_secs(1_791_187_200);
518 for (started, method, path) in [
519 (september, "POST", "/v1/index"),
520 (september, "GET", "/v1/graphs/x/stats"),
521 (september, "GET", "/v1/graphs/x/verify"),
522 (october, "GET", "/v1/graphs/x/stats"),
523 ] {
524 meter.append(&meter.build("agent", "hosted", method, path, 200, 1, started));
525 }
526 meter.append(&meter.build("other", "hosted", "GET", "/v1/graphs/x/stats", 200, 1, september));
527
528 assert_eq!(
529 meter.usage_in_month("agent", "2026-09"),
530 MonthUsage {
531 queries: 2,
532 index_ops: 1
533 }
534 );
535 assert_eq!(
536 meter.usage_in_month("agent", "2026-10"),
537 MonthUsage {
538 queries: 1,
539 index_ops: 0
540 }
541 );
542 assert_eq!(
543 meter.usage_in_month("other", "2026-09"),
544 MonthUsage {
545 queries: 1,
546 index_ops: 0
547 }
548 );
549 assert_eq!(
550 meter.usage_in_month("ghost", "2026-09"),
551 MonthUsage::default(),
552 "an unknown key has used nothing"
553 );
554 }
555
556 #[test]
560 fn query_count_reaches_trips_exactly_at_the_limit() {
561 let dir = tempfile::tempdir().unwrap();
562 let meter = meter(&dir);
563 let september = UNIX_EPOCH + Duration::from_secs(1_788_595_200);
564 assert!(
565 !meter.query_count_reaches("agent", "2026-09", 2),
566 "a missing log is zero usage, never a refusal"
567 );
568 for _ in 0..2 {
569 meter.append(&meter.build("agent", "hosted", "GET", "/v1/x", 200, 1, september));
570 }
571 meter.append(&meter.build("agent", "hosted", "POST", "/v1/index", 200, 1, september));
574 assert!(!meter.query_count_reaches("agent", "2026-09", 3));
575 assert!(meter.query_count_reaches("agent", "2026-09", 2));
576 assert!(meter.query_count_reaches("agent", "2026-09", 1));
577 assert!(
578 !meter.query_count_reaches("agent", "2026-10", 1),
579 "another month is another allowance"
580 );
581 }
582
583 #[test]
584 fn current_month_is_a_utc_month_prefix() {
585 let month = current_month();
586 assert_eq!(month.len(), 7, "{month}");
587 assert_eq!(&month[4..5], "-");
588 let mm: u32 = month[5..7].parse().expect("month digits");
589 assert!((1..=12).contains(&mm), "{month}");
590 }
591
592 #[test]
595 fn query_strings_are_not_part_of_the_recorded_path() {
596 let dir = tempfile::tempdir().unwrap();
597 let built = meter(&dir).build(
598 "agent",
599 "hosted",
600 "GET",
601 "/v1/graphs/x/stats",
602 200,
603 0,
604 SystemTime::now(),
605 );
606 assert!(!built.path.contains('?'));
607 }
608
609 #[test]
613 fn unwritable_path_is_logged_not_panicked() {
614 let dir = tempfile::tempdir().unwrap();
615 let blocker = dir.path().join("blocker");
617 std::fs::write(&blocker, b"not a directory").unwrap();
618 let meter = Metering::new(&blocker.join("metering.log"));
619 meter.append(&record("agent", 200));
620 }
621
622 #[test]
626 fn append_after_truncation_writes_to_the_current_file() {
627 let dir = tempfile::tempdir().unwrap();
628 let meter = meter(&dir);
629 meter.append(&record("agent", 200));
630 std::fs::write(meter.path(), b"").unwrap();
631 meter.append(&record("agent", 404));
632 let contents = std::fs::read_to_string(meter.path()).unwrap();
633 assert_eq!(contents.lines().count(), 1);
634 }
635}