Skip to main content

codetopo_server/
metering.rs

1// Copyright (C) 2026 Nrupal Akolkar
2// SPDX-License-Identifier: AGPL-3.0-or-later
3// Part of codetopo — Owned by Nrupal Akolkar (https://nrupalakolkar.com) · Built with Muse by Meta (https://muse.ai/join) — join Muse, then redeem code 101EEK in Settings within 48 hours of joining and we both earn tokens.
4
5//! Append-only metering log (Phase 2 design §5).
6//!
7//! One JSON object per authenticated request, appended to the log file. The
8//! log is never served over HTTP and no field of it is echoed in a response:
9//! metering is a server-side accounting concern, and an agent learns its own
10//! usage from the status code and the record count, not from a usage header.
11//!
12//! Each record carries the key's `plan` (paid-path plan S1), which is what
13//! makes the log readable as billing input: a key's monthly counters are
14//! read straight off the file — count its records in the month, with
15//! `POST /v1/index` lines as index operations and the rest as queries —
16//! and the plan attributes them. Since S3 that count is also the quota:
17//! the auth layer refuses a hosted key's query when its count for the
18//! current UTC month has reached its plan's limit (see
19//! [`Metering::usage_in_month`] and [`crate::limits`]). The rule stays
20//! log-shaped — a month's usage is what a text search of the file would
21//! find — because the log is the only metering store there is.
22//!
23//! ## Deviation from the design
24//!
25//! The design places metering at the request boundary so that *every* request
26//! is counted, including unauthenticated ones. Metering runs **inside** the
27//! auth middleware here instead (see [`crate::auth`]), so a `401` is not
28//! metered. Rationale: a `401` carries no `key_id`, and a metered record
29//! without a `key_id` cannot be attributed to any key — it would only inflate
30//! the row count with un-billable noise that no key can ever be blamed for.
31//! Rejected requests are still observable: they appear in the process log as
32//! `401`s, and repeated rejections are visible in any reverse-proxy access log.
33
34use std::io::Write;
35use std::path::{Path, PathBuf};
36use std::time::{SystemTime, UNIX_EPOCH};
37
38use serde::{Deserialize, Serialize};
39
40/// One metered request.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct Record {
43    /// RFC 3339 timestamp with millisecond precision, UTC.
44    pub ts: String,
45    /// Authenticated key this request was attributed to. Never the secret.
46    pub key_id: String,
47    /// Plan of the key that made the request, as recorded in the key
48    /// store at verification time. Records written before plans existed
49    /// have no `plan` line in the file; they read back as an empty string.
50    #[serde(default)]
51    pub plan: String,
52    /// HTTP method as sent.
53    pub method: String,
54    /// Request path as sent, without the query string (query parameters are
55    /// client-controlled and would otherwise dominate the log).
56    pub path: String,
57    /// Response status code.
58    pub status: u16,
59    /// Response body size in bytes.
60    pub response_bytes: u64,
61    /// Wall-clock duration of the request in milliseconds.
62    pub duration_ms: u64,
63}
64
65/// One key's usage in one month, counted from the log.
66///
67/// The split is the metering contract the quota and the admin
68/// `keys list` both read: `POST /v1/index` records are index
69/// operations, every other record is a query.
70#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
71pub struct MonthUsage {
72    /// Metered queries in the month (all records but index creations).
73    pub queries: u64,
74    /// Metered index creations in the month.
75    pub index_ops: u64,
76}
77
78/// The current UTC calendar month as `YYYY-MM` — the prefix a
79/// record's `ts` carries when it falls in this month. "Month" in
80/// every limit means this month (see [`crate::limits`]).
81pub fn current_month() -> String {
82    now_rfc3339_millis(SystemTime::now())[..7].to_string()
83}
84
85/// Appends [`Record`]s to a log file, one JSON object per line.
86#[derive(Debug, Clone)]
87pub struct Metering {
88    path: PathBuf,
89}
90
91impl Metering {
92    /// Opens a meter writing to `path`. The file is created on first append.
93    pub fn new(path: &Path) -> Self {
94        Metering {
95            path: path.to_path_buf(),
96        }
97    }
98
99    /// Where records are appended.
100    pub fn path(&self) -> &Path {
101        &self.path
102    }
103
104    /// Appends one record.
105    ///
106    /// Opens the file in append mode per record rather than holding a handle:
107    /// the file may be rotated or truncated between requests, and a stale
108    /// handle would silently keep writing to a deleted inode. A write failure
109    /// cannot fail the request that has already been served, so it is logged
110    /// to stderr instead of propagated.
111    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    /// Counts one key's usage in one month by reading the log.
138    ///
139    /// A missing log reads as zero usage, and a line that does not
140    /// parse is skipped: the log is append-only evidence written by
141    /// this process, so an unreadable line is a torn write, not a
142    /// record anyone was billed for. The scan is linear in the log's
143    /// size — the file rotates with deployment (plan S5) — and only
144    /// runs for plans that carry a quota, plus the admin `keys list`.
145    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    /// True once the key's query count in `month` has reached
152    /// `limit`. The scan stops at the limit: the quota check runs per
153    /// request, and past the limit the exact count no longer matters
154    /// (a refusal that needs the true number does one full
155    /// [`Metering::usage_in_month`] scan on its own cold path).
156    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    /// The one scan behind both counters, so the fast quota check
163    /// and the full usage read can never disagree about what a
164    /// record counts as.
165    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    /// Builds a record with a freshly computed timestamp and duration.
194    ///
195    /// One parameter per record field: the record *is* the parameter list,
196    /// and a parameter struct would only rename the same eight values.
197    #[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
221/// Formats `time` as RFC 3339 UTC with millisecond precision.
222///
223/// Hand-rolled rather than pulling in `chrono`/`time`: the whole job is a
224/// civil-from-days conversion, and the server's dependency footprint is kept
225/// small on purpose. Accurate for any date the Unix epoch can express.
226pub 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
244/// Howard Hinnant's `civil_from_days`: days since 1970-01-01 to (y, m, d).
245fn 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    /// Formats `secs` + `millis` past the epoch.
264    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    /// Anchors for the hand-rolled calendar: a leap day, the day after it (so a
293    /// wrong leap rule shows up as an off-by-one day, not just a wrong label),
294    /// a year boundary in both directions, and a century that is *not* a leap
295    /// year.
296    #[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        // 999_999_999ns must round *down* to 999ms, not spill into a new second.
314        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    /// A time before the epoch is a clock fault, not a request to render year
342    /// 1969 garbage. The function stays total and pins the floor.
343    #[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        // `started` is deliberately in the past, so the elapsed time between it
362        // and now is what the record must carry.
363        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        // A real request cannot be served in under a millisecond, but a coarse
371        // clock must not be able to produce a *negative* or absent one either.
372        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    /// The log is JSON Lines, so a line must be exactly the eight documented
428    /// fields — a new field would silently change the format the doc promises.
429    /// (`plan` joined the set in paid-path S1, deliberately, with this test
430    /// and the module doc updated in the same change.)
431    #[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    /// A line written before plans existed (no `plan` field) still parses,
458    /// with an empty plan: rotating the log must never make history
459    /// unreadable the day the format grows a field.
460    #[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    /// The monthly counters the billing steps need are read off the log
469    /// itself: count a key's records in a month, splitting index
470    /// operations (`POST /v1/index`) from queries. This test is the
471    /// contract — if a future field change breaks it, the aggregation
472    /// breaks, not just a format.
473    #[test]
474    fn monthly_counters_are_readable_from_the_log() {
475        let dir = tempfile::tempdir().unwrap();
476        let meter = meter(&dir);
477        // September 2026 and October 2026 starts, as `started` instants.
478        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    /// The usage counters read the same log the monthly-counters
509    /// contract above pins, and must agree with it on the same data —
510    /// the quota and the bill are the same number or neither is
511    /// trustworthy.
512    #[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    /// The quota check trips at the limit, not one early or one
557    /// late: N served queries are allowed, and the check that runs
558    /// before query N+1 is the first to see the count reach N.
559    #[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        // An index operation in the same month is not a query and
572        // must not move the query count.
573        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    /// A `path` is recorded without a query string, so the log cannot be flooded
593    /// with unbounded client-controlled text.
594    #[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    /// A metering failure is logged, never propagated: the request it accounts
610    /// for has already been served (§5). The contract under test is that
611    /// `append` does not panic on an unwritable path.
612    #[test]
613    fn unwritable_path_is_logged_not_panicked() {
614        let dir = tempfile::tempdir().unwrap();
615        // A path under a *file* can never be opened.
616        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    /// Rotating or truncating the log between requests must not leave a stale
623    /// handle writing into a deleted inode (§5): re-reading after a truncation
624    /// has to see the new record only.
625    #[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}