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//! ## Deviation from the design
13//!
14//! The design places metering at the request boundary so that *every* request
15//! is counted, including unauthenticated ones. Metering runs **inside** the
16//! auth middleware here instead (see [`crate::auth`]), so a `401` is not
17//! metered. Rationale: a `401` carries no `key_id`, and a metered record
18//! without a `key_id` cannot be attributed to any key — it would only inflate
19//! the row count with un-billable noise that no key can ever be blamed for.
20//! Rejected requests are still observable: they appear in the process log as
21//! `401`s, and repeated rejections are visible in any reverse-proxy access log.
22
23use std::io::Write;
24use std::path::{Path, PathBuf};
25use std::time::{SystemTime, UNIX_EPOCH};
26
27use serde::{Deserialize, Serialize};
28
29/// One metered request.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct Record {
32    /// RFC 3339 timestamp with millisecond precision, UTC.
33    pub ts: String,
34    /// Authenticated key this request was attributed to. Never the secret.
35    pub key_id: String,
36    /// HTTP method as sent.
37    pub method: String,
38    /// Request path as sent, without the query string (query parameters are
39    /// client-controlled and would otherwise dominate the log).
40    pub path: String,
41    /// Response status code.
42    pub status: u16,
43    /// Response body size in bytes.
44    pub response_bytes: u64,
45    /// Wall-clock duration of the request in milliseconds.
46    pub duration_ms: u64,
47}
48
49/// Appends [`Record`]s to a log file, one JSON object per line.
50#[derive(Debug, Clone)]
51pub struct Metering {
52    path: PathBuf,
53}
54
55impl Metering {
56    /// Opens a meter writing to `path`. The file is created on first append.
57    pub fn new(path: &Path) -> Self {
58        Metering {
59            path: path.to_path_buf(),
60        }
61    }
62
63    /// Where records are appended.
64    pub fn path(&self) -> &Path {
65        &self.path
66    }
67
68    /// Appends one record.
69    ///
70    /// Opens the file in append mode per record rather than holding a handle:
71    /// the file may be rotated or truncated between requests, and a stale
72    /// handle would silently keep writing to a deleted inode. A write failure
73    /// cannot fail the request that has already been served, so it is logged
74    /// to stderr instead of propagated.
75    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    /// Builds a record with a freshly computed timestamp and duration.
102    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
123/// Formats `time` as RFC 3339 UTC with millisecond precision.
124///
125/// Hand-rolled rather than pulling in `chrono`/`time`: the whole job is a
126/// civil-from-days conversion, and the server's dependency footprint is kept
127/// small on purpose. Accurate for any date the Unix epoch can express.
128pub 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
146/// Howard Hinnant's `civil_from_days`: days since 1970-01-01 to (y, m, d).
147fn 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    /// Formats `secs` + `millis` past the epoch.
166    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    /// Anchors for the hand-rolled calendar: a leap day, the day after it (so a
194    /// wrong leap rule shows up as an off-by-one day, not just a wrong label),
195    /// a year boundary in both directions, and a century that is *not* a leap
196    /// year.
197    #[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        // 999_999_999ns must round *down* to 999ms, not spill into a new second.
215        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    /// A time before the epoch is a clock fault, not a request to render year
243    /// 1969 garbage. The function stays total and pins the floor.
244    #[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        // `started` is deliberately in the past, so the elapsed time between it
262        // and now is what the record must carry.
263        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        // A real request cannot be served in under a millisecond, but a coarse
271        // clock must not be able to produce a *negative* or absent one either.
272        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    /// The log is JSON Lines, so a line must be exactly the seven documented
328    /// fields — a new field would silently change the format the doc promises.
329    #[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    /// A `path` is recorded without a query string, so the log cannot be flooded
355    /// with unbounded client-controlled text.
356    #[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    /// A metering failure is logged, never propagated: the request it accounts
371    /// for has already been served (§5). The contract under test is that
372    /// `append` does not panic on an unwritable path.
373    #[test]
374    fn unwritable_path_is_logged_not_panicked() {
375        let dir = tempfile::tempdir().unwrap();
376        // A path under a *file* can never be opened.
377        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    /// Rotating or truncating the log between requests must not leave a stale
384    /// handle writing into a deleted inode (§5): re-reading after a truncation
385    /// has to see the new record only.
386    #[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}