observer_storage/
storage.rs1use std::fs::{self, OpenOptions};
10use std::io::{Read, Write};
11use std::path::Path;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14use flate2::read::MultiGzDecoder;
15use flate2::write::GzEncoder;
16use flate2::Compression;
17use serde_json::Value;
18
19pub fn now_ms() -> i64 {
20 SystemTime::now()
21 .duration_since(UNIX_EPOCH)
22 .map(|d| d.as_millis() as i64)
23 .unwrap_or(0)
24}
25
26#[derive(Clone, Copy, Default)]
28pub struct WriteOpts {
29 pub gzip: bool,
30}
31
32impl WriteOpts {
33 pub fn plain() -> Self {
34 Self { gzip: false }
35 }
36 pub fn gzip() -> Self {
37 Self { gzip: true }
38 }
39}
40
41pub fn append_lifecycle(dir: &Path, evt: Value) -> Result<(), String> {
42 let path = dir.join("windows.jsonl");
43 let mut f = OpenOptions::new()
44 .create(true)
45 .append(true)
46 .open(&path)
47 .map_err(|e| e.to_string())?;
48 writeln!(f, "{}", evt).map_err(|e| e.to_string())
49}
50
51pub fn append_events_file(dir: &Path, segment_id: &str, events: &[Value]) -> Result<(), String> {
53 append_events_file_with(dir, segment_id, events, &WriteOpts::plain())
54}
55
56pub fn append_events_file_with(
61 dir: &Path,
62 segment_id: &str,
63 events: &[Value],
64 opts: &WriteOpts,
65) -> Result<(), String> {
66 let seg_dir = dir.join("segments");
67 fs::create_dir_all(&seg_dir).map_err(|e| e.to_string())?;
68 let fname = if opts.gzip {
69 format!("{}.jsonl.gz", segment_id)
70 } else {
71 format!("{}.jsonl", segment_id)
72 };
73 let path = seg_dir.join(&fname);
74 let f = OpenOptions::new()
75 .create(true)
76 .append(true)
77 .open(&path)
78 .map_err(|e| e.to_string())?;
79 if opts.gzip {
80 let mut enc = GzEncoder::new(f, Compression::default());
81 for e in events {
82 writeln!(enc, "{}", e).map_err(|e| e.to_string())?;
83 }
84 enc.finish().map_err(|e| e.to_string())?;
85 } else {
86 let mut f = f;
87 for e in events {
88 writeln!(f, "{}", e).map_err(|e| e.to_string())?;
89 }
90 }
91 Ok(())
92}
93
94pub fn read_segment_events(path: &Path) -> Vec<Value> {
97 let Some(raw) = read_segment_text(path) else {
98 return Vec::new();
99 };
100 raw.lines()
101 .filter(|l| !l.trim().is_empty())
102 .filter_map(|l| serde_json::from_str(l).ok())
103 .collect()
104}
105
106pub fn segment_id_from_filename(name: &str) -> &str {
108 name.strip_suffix(".jsonl.gz")
109 .or_else(|| name.strip_suffix(".jsonl"))
110 .unwrap_or(name)
111}
112
113pub fn segment_path(dir: &Path, segment_id: &str) -> Option<std::path::PathBuf> {
115 let seg_dir = dir.join("segments");
116 let gz = seg_dir.join(format!("{segment_id}.jsonl.gz"));
117 if gz.is_file() {
118 return Some(gz);
119 }
120 let plain = seg_dir.join(format!("{segment_id}.jsonl"));
121 if plain.is_file() {
122 return Some(plain);
123 }
124 None
125}
126
127pub fn read_segment_first_line(path: &Path) -> Option<String> {
132 use std::io::BufRead;
133 let f = fs::File::open(path).ok()?;
134 let is_gz = path.extension().and_then(|e| e.to_str()) == Some("gz");
135 let mut reader: Box<dyn BufRead> = if is_gz {
136 Box::new(std::io::BufReader::new(MultiGzDecoder::new(f)))
137 } else {
138 Box::new(std::io::BufReader::new(f))
139 };
140 let mut line = String::new();
141 reader.read_line(&mut line).ok()?;
142 let t = line.trim();
143 if t.is_empty() {
144 None
145 } else {
146 Some(t.to_string())
147 }
148}
149
150pub fn read_segment_last_line(path: &Path) -> Option<String> {
156 use std::io::{Seek, SeekFrom};
157 let is_gz = path.extension().and_then(|e| e.to_str()) == Some("gz");
158 if is_gz {
159 let text = read_segment_text(path)?;
160 return text
161 .lines()
162 .rev()
163 .find(|l| !l.trim().is_empty())
164 .map(|l| l.trim().to_string());
165 }
166 let mut f = fs::File::open(path).ok()?;
167 let len = f.metadata().ok()?.len();
168 let mut window: u64 = 1 << 20; loop {
170 let start = len.saturating_sub(window);
171 f.seek(SeekFrom::Start(start)).ok()?;
172 let mut buf = Vec::with_capacity((len - start) as usize);
173 f.read_to_end(&mut buf).ok()?;
174 let text = String::from_utf8_lossy(&buf);
175 let mut parts: Vec<&str> = text.split('\n').collect();
176 if start > 0 && !parts.is_empty() {
178 parts.remove(0);
179 }
180 if let Some(last) = parts.iter().rev().find(|l| !l.trim().is_empty()) {
181 return Some(last.trim().to_string());
182 }
183 if start == 0 {
184 return None;
185 }
186 window *= 4;
187 }
188}
189
190pub(crate) fn read_segment_text(path: &Path) -> Option<String> {
191 let is_gz = path.extension().and_then(|e| e.to_str()) == Some("gz");
192 if is_gz {
193 let f = fs::File::open(path).ok()?;
194 let mut dec = MultiGzDecoder::new(f);
195 let mut s = String::new();
196 dec.read_to_string(&mut s).ok()?;
197 Some(s)
198 } else {
199 fs::read_to_string(path).ok()
200 }
201}
202
203pub fn create_session(dir: &Path, meta: Value) -> Result<(), String> {
205 fs::create_dir_all(dir.join("segments")).map_err(|e| e.to_string())?;
206 fs::write(dir.join("session.json"), meta.to_string()).map_err(|e| e.to_string())
207}
208
209pub fn finalize_session(dir: &Path, ended_at: i64) -> Result<(), String> {
211 let path = dir.join("session.json");
212 let mut v = serde_json::from_str::<Value>(
213 &fs::read_to_string(&path).map_err(|e| e.to_string())?,
214 )
215 .map_err(|e| e.to_string())?;
216 if let Some(obj) = v.as_object_mut() {
217 obj.insert("endedAt".into(), serde_json::json!(ended_at));
218 }
219 fs::write(path, v.to_string()).map_err(|e| e.to_string())
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use serde_json::json;
226
227 #[test]
228 fn append_events_writes_jsonl() {
229 let dir = tempfile::tempdir().unwrap();
230 append_events_file(
231 dir.path(),
232 "main#0",
233 &[json!({ "type": 2, "timestamp": 1 }), json!({ "type": 6, "timestamp": 2 })],
234 )
235 .unwrap();
236 let lines: Vec<Value> = fs::read_to_string(dir.path().join("segments/main#0.jsonl"))
237 .unwrap()
238 .lines()
239 .filter(|l| !l.is_empty())
240 .map(|l| serde_json::from_str(l).unwrap())
241 .collect();
242 assert_eq!(lines.len(), 2);
243 assert_eq!(lines[1]["type"], 6);
244 }
245
246 #[test]
248 fn append_events_gzip_roundtrip() {
249 let dir = tempfile::tempdir().unwrap();
250 let seg = "web#1";
251 append_events_file_with(
252 dir.path(),
253 seg,
254 &[json!({ "type": 2, "timestamp": 1 })],
255 &WriteOpts::gzip(),
256 )
257 .unwrap();
258 append_events_file_with(
259 dir.path(),
260 seg,
261 &[json!({ "type": 6, "timestamp": 2 })],
262 &WriteOpts::gzip(),
263 )
264 .unwrap();
265
266 let path = dir.path().join("segments/web#1.jsonl.gz");
267 assert!(path.exists(), ".gz 文件应存在");
268 assert!(
269 !dir.path().join("segments/web#1.jsonl").exists(),
270 "不应同时存在 plain 文件"
271 );
272
273 let events = read_segment_events(&path);
274 assert_eq!(events.len(), 2);
275 assert_eq!(events[0]["type"], 2);
276 assert_eq!(events[1]["type"], 6);
277 }
278
279 #[test]
281 fn read_segment_events_plain_and_gz() {
282 let dir = tempfile::tempdir().unwrap();
283 append_events_file(dir.path(), "a#0", &[json!({ "type": 2, "timestamp": 1 })]).unwrap();
284 let plain = read_segment_events(&dir.path().join("segments/a#0.jsonl"));
285 assert_eq!(plain.len(), 1);
286
287 append_events_file_with(
288 dir.path(),
289 "b#0",
290 &[json!({ "type": 2, "timestamp": 2 })],
291 &WriteOpts::gzip(),
292 )
293 .unwrap();
294 let gz = read_segment_events(&dir.path().join("segments/b#0.jsonl.gz"));
295 assert_eq!(gz.len(), 1);
296 assert_eq!(gz[0]["timestamp"], 2);
297 }
298
299 #[test]
300 fn segment_id_from_filename_strips_extensions() {
301 assert_eq!(segment_id_from_filename("web#1.jsonl.gz"), "web#1");
302 assert_eq!(segment_id_from_filename("web#1.jsonl"), "web#1");
303 assert_eq!(segment_id_from_filename("web#1"), "web#1");
304 }
305}