1use crate::{ObservationEvent, ObservationSink};
4use parking_lot::Mutex;
5use serde::Serialize;
6use std::fs::{self, File, OpenOptions};
7use std::io::{BufRead, BufReader, Write};
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::mpsc::{self, Receiver, SyncSender, TrySendError};
11use std::sync::Arc;
12use std::thread::JoinHandle;
13
14pub const OBSERVATION_FILE_FORMAT_V1: &str = "# appcore-observations-v1";
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct FileObservationSinkConfig {
20 pub path: PathBuf,
22 pub max_file_bytes: u64,
24 pub retained_files: usize,
26 pub queue_capacity: usize,
28 pub sync_every_records: usize,
30}
31
32impl FileObservationSinkConfig {
33 pub fn new(path: impl Into<PathBuf>) -> Self {
35 Self {
36 path: path.into(),
37 max_file_bytes: 16 * 1024 * 1024,
38 retained_files: 4,
39 queue_capacity: 4_096,
40 sync_every_records: 64,
41 }
42 }
43
44 fn validate(&self) -> std::io::Result<()> {
45 if self.max_file_bytes < 64 * 1024
46 || self.retained_files == 0
47 || self.queue_capacity == 0
48 || self.sync_every_records == 0
49 {
50 return Err(std::io::Error::new(
51 std::io::ErrorKind::InvalidInput,
52 "observation drain limits must be positive and max_file_bytes >= 64 KiB",
53 ));
54 }
55 Ok(())
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct FileObservationSinkStats {
62 pub written: u64,
64 pub dropped: u64,
66 pub errors: u64,
68}
69
70enum DrainCommand {
71 Event(ObservationEvent),
72 Flush(mpsc::Sender<()>),
73}
74
75struct FileObservationSinkInner {
76 sender: Mutex<Option<SyncSender<DrainCommand>>>,
77 worker: Mutex<Option<JoinHandle<()>>>,
78 written: Arc<AtomicU64>,
79 dropped: AtomicU64,
80 errors: Arc<AtomicU64>,
81}
82
83impl Drop for FileObservationSinkInner {
84 fn drop(&mut self) {
85 self.sender.get_mut().take();
86 if let Some(worker) = self.worker.get_mut().take() {
87 let _ = worker.join();
88 }
89 }
90}
91
92#[derive(Clone)]
94pub struct FileObservationSink {
95 inner: Arc<FileObservationSinkInner>,
96}
97
98impl std::fmt::Debug for FileObservationSink {
99 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 formatter
101 .debug_struct("FileObservationSink")
102 .field("stats", &self.stats())
103 .finish()
104 }
105}
106
107impl FileObservationSink {
108 pub fn new(config: FileObservationSinkConfig) -> std::io::Result<Self> {
110 config.validate()?;
111 initialize_file(&config.path)?;
112 let (sender, receiver) = mpsc::sync_channel(config.queue_capacity);
113 let written = Arc::new(AtomicU64::new(0));
114 let errors = Arc::new(AtomicU64::new(0));
115 let worker_written = Arc::clone(&written);
116 let worker_errors = Arc::clone(&errors);
117 let worker = std::thread::Builder::new()
118 .name("appcore-observation-drain".to_string())
119 .spawn(move || run_worker(config, receiver, worker_written, worker_errors))?;
120 Ok(Self {
121 inner: Arc::new(FileObservationSinkInner {
122 sender: Mutex::new(Some(sender)),
123 worker: Mutex::new(Some(worker)),
124 written,
125 dropped: AtomicU64::new(0),
126 errors,
127 }),
128 })
129 }
130
131 pub fn flush(&self) -> std::io::Result<()> {
133 let (acknowledge, receiver) = mpsc::channel();
134 let sender = self.inner.sender.lock().clone().ok_or_else(|| {
135 std::io::Error::new(std::io::ErrorKind::BrokenPipe, "observation drain stopped")
136 })?;
137 sender
138 .send(DrainCommand::Flush(acknowledge))
139 .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "drain stopped"))?;
140 receiver
141 .recv()
142 .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "drain stopped"))
143 }
144
145 pub fn stats(&self) -> FileObservationSinkStats {
147 FileObservationSinkStats {
148 written: self.inner.written.load(Ordering::Relaxed),
149 dropped: self.inner.dropped.load(Ordering::Relaxed),
150 errors: self.inner.errors.load(Ordering::Relaxed),
151 }
152 }
153}
154
155impl ObservationSink for FileObservationSink {
156 fn emit(&self, event: ObservationEvent) {
157 let Some(sender) = self.inner.sender.lock().clone() else {
158 self.inner.dropped.fetch_add(1, Ordering::Relaxed);
159 return;
160 };
161 match sender.try_send(DrainCommand::Event(event.redacted())) {
162 Ok(()) => {}
163 Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) => {
164 self.inner.dropped.fetch_add(1, Ordering::Relaxed);
165 }
166 }
167 }
168}
169
170fn run_worker(
171 config: FileObservationSinkConfig,
172 receiver: Receiver<DrainCommand>,
173 written: Arc<AtomicU64>,
174 errors: Arc<AtomicU64>,
175) {
176 let mut unsynced = 0usize;
177 let mut file = open_append(&config.path).ok();
178 while let Ok(command) = receiver.recv() {
179 match command {
180 DrainCommand::Event(event) => {
181 let result = write_event(&config, &mut file, &event);
182 if result.is_ok() {
183 written.fetch_add(1, Ordering::Relaxed);
184 unsynced += 1;
185 } else {
186 errors.fetch_add(1, Ordering::Relaxed);
187 }
188 if unsynced >= config.sync_every_records {
189 sync_file(&mut file, &errors);
190 unsynced = 0;
191 }
192 }
193 DrainCommand::Flush(acknowledge) => {
194 sync_file(&mut file, &errors);
195 unsynced = 0;
196 let _ = acknowledge.send(());
197 }
198 }
199 }
200 sync_file(&mut file, &errors);
201}
202
203fn write_event(
204 config: &FileObservationSinkConfig,
205 file: &mut Option<File>,
206 event: &ObservationEvent,
207) -> std::io::Result<()> {
208 let mut line = serde_json::to_vec(&VersionedObservation::new(event))?;
209 line.push(b'\n');
210 let current_size = file
211 .as_ref()
212 .and_then(|file| file.metadata().ok())
213 .map(|metadata| metadata.len())
214 .unwrap_or(0);
215 if current_size.saturating_add(line.len() as u64) > config.max_file_bytes {
216 if let Some(active) = file.take() {
217 active.sync_all()?;
218 }
219 rotate_files(config)?;
220 *file = Some(open_append(&config.path)?);
221 }
222 if file.is_none() {
223 *file = Some(open_append(&config.path)?);
224 }
225 match file.as_mut() {
226 Some(file) => file.write_all(&line),
227 None => Err(std::io::Error::other(
228 "observation file was not initialized",
229 )),
230 }
231}
232
233#[derive(Serialize)]
234struct VersionedObservation<'a> {
235 schema: &'static str,
236 event: &'a ObservationEvent,
237}
238
239impl<'a> VersionedObservation<'a> {
240 fn new(event: &'a ObservationEvent) -> Self {
241 Self {
242 schema: "appcore.observation.v1",
243 event,
244 }
245 }
246}
247
248fn initialize_file(path: &Path) -> std::io::Result<()> {
249 let parent = path.parent().unwrap_or_else(|| Path::new("."));
250 fs::create_dir_all(parent)?;
251 reject_symlink(path)?;
252 if !path.exists() {
253 let mut file = OpenOptions::new().create_new(true).write(true).open(path)?;
254 writeln!(file, "{OBSERVATION_FILE_FORMAT_V1}")?;
255 file.sync_all()?;
256 sync_parent(parent)?;
257 return Ok(());
258 }
259 let mut first = String::new();
260 BufReader::new(File::open(path)?).read_line(&mut first)?;
261 if first.trim_end() != OBSERVATION_FILE_FORMAT_V1 {
262 return Err(std::io::Error::new(
263 std::io::ErrorKind::InvalidData,
264 "unsupported observation file format",
265 ));
266 }
267 Ok(())
268}
269
270fn open_append(path: &Path) -> std::io::Result<File> {
271 initialize_file(path)?;
272 OpenOptions::new().append(true).read(true).open(path)
273}
274
275fn rotate_files(config: &FileObservationSinkConfig) -> std::io::Result<()> {
276 for index in (1..=config.retained_files).rev() {
277 let source = rotated_path(&config.path, index);
278 if index == config.retained_files {
279 remove_if_exists(&source)?;
280 } else if source.exists() {
281 fs::rename(&source, rotated_path(&config.path, index + 1))?;
282 }
283 }
284 if config.path.exists() {
285 fs::rename(&config.path, rotated_path(&config.path, 1))?;
286 }
287 initialize_file(&config.path)
288}
289
290fn rotated_path(path: &Path, index: usize) -> PathBuf {
291 let name = path
292 .file_name()
293 .and_then(|name| name.to_str())
294 .unwrap_or("observations.jsonl");
295 path.with_file_name(format!("{name}.{index}"))
296}
297
298fn reject_symlink(path: &Path) -> std::io::Result<()> {
299 match fs::symlink_metadata(path) {
300 Ok(metadata) if metadata.file_type().is_symlink() => Err(std::io::Error::new(
301 std::io::ErrorKind::InvalidInput,
302 "observation path must not be a symlink",
303 )),
304 Ok(metadata) if !metadata.is_file() => Err(std::io::Error::new(
305 std::io::ErrorKind::InvalidInput,
306 "observation path must be a regular file",
307 )),
308 Ok(_) => Ok(()),
309 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
310 Err(error) => Err(error),
311 }
312}
313
314fn sync_file(file: &mut Option<File>, errors: &AtomicU64) {
315 if file.as_ref().is_some_and(|file| file.sync_all().is_err()) {
316 errors.fetch_add(1, Ordering::Relaxed);
317 }
318}
319
320fn remove_if_exists(path: &Path) -> std::io::Result<()> {
321 match fs::remove_file(path) {
322 Ok(()) => Ok(()),
323 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
324 Err(error) => Err(error),
325 }
326}
327
328#[cfg(unix)]
329fn sync_parent(path: &Path) -> std::io::Result<()> {
330 File::open(path)?.sync_all()
331}
332
333#[cfg(not(unix))]
334fn sync_parent(_path: &Path) -> std::io::Result<()> {
335 Ok(())
336}
337
338#[cfg(test)]
339#[path = "observation_file_tests.rs"]
340mod tests;