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