Skip to main content

observer_storage/
storage.rs

1//! 会话存储原语:落盘函数,全部吃 `&Path`,零 tauri 依赖。
2//!
3//! 供 [`tauri_plugin_observer`](Local 模式 self-obs 落盘)与
4//! [`observer_server`](../../observer-server)(HTTP 落盘)共用。
5//!
6//! 格式:`recordings/<sessionId>/{session.json, windows.jsonl, segments/<label>#<n>.jsonl[.gz]}`。
7//! P9 起 segments 可选 gzip 落盘(`.jsonl.gz`),读路径按扩展名透明解压。
8
9use 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/// segment 落盘选项。`gzip=true` 写 `.jsonl.gz`(每批一个 gzip member,可追加)。
27#[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
51/// 追加事件到 segment 文件(plain JSONL,向后兼容)。
52pub 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
56/// 追加事件到 segment 文件,按 [`WriteOpts`] 决定是否 gzip。
57///
58/// gzip 模式下每次调用写一个独立 gzip member(concatenate 到同一文件),
59/// 读侧用 [`read_segment_events`] 经 [`MultiGzDecoder`] 透明解压全部 member。
60pub 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
94/// 读 segment 文件事件(gzip 感知:`.jsonl.gz` 用 [`MultiGzDecoder`],`.jsonl` 直读)。
95/// 文件不存在或单行解析失败被跳过。
96pub 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
106/// 由文件名还原 segmentId:`web#1.jsonl.gz` / `web#1.jsonl` -> `web#1`。
107pub 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
113/// 定位段文件:优先 `.jsonl.gz`,回退 `.jsonl`(与写侧扩展名一致)。
114pub 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
127/// 只读段文件首行(Meta 事件,含录制视口宽高)。
128///
129/// gz 感知且**不读入整段**:gz 段是「每批一个 member」的前缀可解压结构,
130/// 解压到第一个换行即停;据此构建段索引是 O(段数) 而非 O(事件数)。
131pub 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
150/// 只读段文件**最后一个非空行**(尾事件的 timestamp 是会话结束时间的兜底依据,
151/// 见 [`crate::bundle::read_session_meta`] 的段索引)。
152///
153/// plain:从文件尾读窗口再扩大,O(1) 量级,不读全文;
154/// gz:gzip 无法逆序解压,只能解压全文取尾行(gz 落盘是 opt-in,量可控)。
155pub 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; // 1MB 起步
169    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        // 窗口没盖到文件开头时,首段可能是半行——丢掉,必要时下一轮扩大窗口
177        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
203/// 建会话目录并写 session.json(meta 由调用方组装好)。
204pub 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
209/// 结束会话:读回 session.json 补 endedAt 后覆写。
210pub 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    /// gzip 追加:两次调用 = 两个 concatenate gzip member,读侧 MultiGzDecoder 还原全部。
247    #[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    /// 读路径同时支持 plain 与 gz,按扩展名分派。
280    #[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}