1use std::io::Write as _;
11use std::path::Path;
12use std::sync::{Mutex, MutexGuard};
13
14use crate::logging::{LOG_ROTATION_MAX_BYTES, RollingFileWriter, default_diagnostics_dir};
15
16const PERF_LOG_NAME: &str = "forge-perf-web.log";
18
19const PERF_ROTATION_FILES: usize = 1;
21
22static WRITE: Mutex<()> = Mutex::new(());
28
29fn write_guard() -> MutexGuard<'static, ()> {
30 match WRITE.lock() {
31 Ok(guard) => guard,
32 Err(poisoned) => poisoned.into_inner(),
35 }
36}
37
38pub(crate) fn append(records: &[serde_json::Value]) -> anyhow::Result<()> {
40 let _guard = write_guard();
41 let dir = default_diagnostics_dir()?;
42 append_in(&dir, records, LOG_ROTATION_MAX_BYTES)
43}
44
45fn append_in(dir: &Path, records: &[serde_json::Value], max_bytes: u64) -> anyhow::Result<()> {
47 std::fs::create_dir_all(dir)?;
48 let path = dir.join(PERF_LOG_NAME);
49 let current = std::fs::metadata(&path).map_or(0, |meta| meta.len());
54 if current >= max_bytes {
55 let rolled = dir.join(format!("{PERF_LOG_NAME}.1"));
56 let _ = std::fs::remove_file(&rolled);
57 std::fs::rename(&path, &rolled)?;
58 }
59 let mut writer = RollingFileWriter::new(&path, true, max_bytes, PERF_ROTATION_FILES)?;
60 for record in records {
61 let line = format!("{}\n", serde_json::to_string(record)?);
63 writer.write_all(line.as_bytes())?;
64 }
65 writer.flush()?;
66 Ok(())
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72
73 #[test]
76 fn a_batch_lands_as_one_json_line_each() {
77 let dir = tempfile::tempdir().expect("tempdir");
78 append_in(
79 dir.path(),
80 &[
81 serde_json::json!({"schema": "forge-perf/v1", "kind": "run_started", "ts_ms": 1}),
82 serde_json::json!({"schema": "forge-perf/v1", "kind": "frame", "ms": 120.0}),
83 ],
84 LOG_ROTATION_MAX_BYTES,
85 )
86 .expect("append");
87
88 let text = std::fs::read_to_string(dir.path().join(PERF_LOG_NAME)).expect("read back");
89 let lines: Vec<&str> = text.lines().collect();
90 assert_eq!(lines.len(), 2, "one line per record: {text}");
91 let parsed: serde_json::Value = serde_json::from_str(lines[1]).expect("a JSON line");
92 assert_eq!(parsed["kind"], "frame");
93 assert_eq!(parsed["ms"], 120.0);
94 }
95
96 #[test]
100 fn a_record_with_a_field_the_server_does_not_know_survives() {
101 let dir = tempfile::tempdir().expect("tempdir");
102 append_in(
103 dir.path(),
104 &[serde_json::json!({"schema": "forge-perf/v1", "kind": "frame", "invented": true})],
105 LOG_ROTATION_MAX_BYTES,
106 )
107 .expect("append");
108
109 let text = std::fs::read_to_string(dir.path().join(PERF_LOG_NAME)).expect("read back");
110 assert!(text.contains("\"invented\":true"), "the page's field survives: {text}");
111 }
112
113 #[test]
117 fn a_file_at_the_cap_is_kept_as_the_rolled_copy() {
118 let dir = tempfile::tempdir().expect("tempdir");
119 std::fs::write(dir.path().join(PERF_LOG_NAME), "x".repeat(20)).expect("seed at the cap");
120 append_in(dir.path(), &[serde_json::json!({"kind": "frame", "ts_ms": 1})], 20)
121 .expect("append");
122
123 let rolled = std::fs::read_to_string(dir.path().join(format!("{PERF_LOG_NAME}.1")))
124 .expect("the previous window");
125 assert_eq!(rolled, "x".repeat(20), "the at-cap copy is kept: {rolled}");
126 let fresh = std::fs::read_to_string(dir.path().join(PERF_LOG_NAME)).expect("read back");
127 assert!(fresh.contains("\"frame\""), "and the new batch is in the fresh file: {fresh}");
128 }
129
130 #[test]
134 fn a_batch_crossing_the_cap_rolls_between_records() {
135 let dir = tempfile::tempdir().expect("tempdir");
136 let record =
137 |ts: u64| serde_json::json!({ "kind": "frame", "ts_ms": ts, "pad": "aaaaaaaa" });
138 append_in(dir.path(), &[record(1), record(2)], 40).expect("append");
139
140 let rolled = std::fs::read_to_string(dir.path().join(format!("{PERF_LOG_NAME}.1")))
141 .expect("the first record rolled");
142 let fresh = std::fs::read_to_string(dir.path().join(PERF_LOG_NAME)).expect("read back");
143 assert!(rolled.contains("\"ts_ms\":1"), "the first record is whole in the roll: {rolled}");
144 assert!(!rolled.contains("\"ts_ms\":2"), "and the second is not in it: {rolled}");
145 assert!(fresh.contains("\"ts_ms\":2"), "the second is whole in the fresh file: {fresh}");
146 }
147}