1use crate::LogEvent;
14use appcore_contracts::ApplicationId;
15use appcore_dnt::{
16 write_atomic, BytesCodec, ContentType, DntKeyProvider, DntOpenOptions, DntSealOptions, KeyId,
17};
18use parking_lot::Mutex;
19use std::collections::VecDeque;
20use std::fs::{self, OpenOptions};
21use std::io::Write;
22use std::path::PathBuf;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum LogError {
27 Io,
29 Capacity,
31 Encryption,
33 Serialization,
35}
36
37pub trait LogSink: Send + Sync {
39 fn emit(&self, event: &LogEvent) -> Result<(), LogError>;
41
42 fn accepts_sensitive(&self) -> bool {
47 false
48 }
49
50 fn name(&self) -> &'static str {
52 "custom"
53 }
54}
55
56#[derive(Debug, Default)]
58pub struct ConsoleSink;
59
60impl ConsoleSink {
61 pub fn new() -> Self {
63 Self
64 }
65}
66
67impl LogSink for ConsoleSink {
68 fn emit(&self, event: &LogEvent) -> Result<(), LogError> {
69 writeln!(
70 std::io::stdout().lock(),
71 "[{:?}][{}][V{}] {}",
72 event.severity,
73 event.component,
74 event.verbosity.value(),
75 event.message
76 )
77 .map_err(|_| LogError::Io)
78 }
79
80 fn name(&self) -> &'static str {
81 "console"
82 }
83}
84
85#[derive(Debug, Clone)]
87pub struct FileSinkConfig {
88 pub path: PathBuf,
90 pub max_bytes: u64,
92 pub sync_each_write: bool,
94 pub retention: u8,
96 pub archive: Option<FileArchiveConfig>,
98}
99
100#[derive(Debug, Clone)]
102pub struct FileArchiveConfig {
103 pub directory: PathBuf,
105 pub max_files: usize,
107}
108
109pub struct FileSink {
111 config: FileSinkConfig,
112 lock: Mutex<()>,
113}
114
115impl FileSink {
116 pub fn new(config: FileSinkConfig) -> Result<Self, LogError> {
118 if config.max_bytes == 0
119 || config.retention > 32
120 || config
121 .archive
122 .as_ref()
123 .is_some_and(|archive| archive.max_files == 0 || archive.max_files > 10_000)
124 {
125 return Err(LogError::Capacity);
126 }
127 Ok(Self {
128 config,
129 lock: Mutex::new(()),
130 })
131 }
132
133 fn rotate(&self, incoming_bytes: usize, timestamp_ms: u64) -> Result<(), LogError> {
134 let path = &self.config.path;
135 ensure_regular_or_missing(path)?;
136 let incoming_bytes = u64::try_from(incoming_bytes).map_err(|_| LogError::Capacity)?;
137 let requires_rotation = fs::metadata(path)
138 .map(|metadata| metadata.len().saturating_add(incoming_bytes) > self.config.max_bytes)
139 .unwrap_or(false);
140 if !requires_rotation {
141 return Ok(());
142 }
143 if self.config.retention == 0 {
144 if let Some(archive) = &self.config.archive {
145 archive_file(path, path, timestamp_ms, archive)?;
146 } else {
147 fs::remove_file(path).map_err(|_| LogError::Io)?;
148 }
149 return Ok(());
150 }
151 let oldest = path.with_extension(format!("jsonl.{}", self.config.retention));
152 ensure_regular_or_missing(&oldest)?;
153 if oldest.exists() {
154 if let Some(archive) = &self.config.archive {
155 archive_file(&oldest, path, timestamp_ms, archive)?;
156 } else {
157 fs::remove_file(oldest).map_err(|_| LogError::Io)?;
158 }
159 }
160 for index in (1..self.config.retention).rev() {
161 let from = path.with_extension(format!("jsonl.{index}"));
162 let to = path.with_extension(format!("jsonl.{}", index + 1));
163 ensure_regular_or_missing(&from)?;
164 ensure_regular_or_missing(&to)?;
165 if from.exists() {
166 fs::rename(from, to).map_err(|_| LogError::Io)?;
167 }
168 }
169 if path.exists() {
170 fs::rename(path, path.with_extension("jsonl.1")).map_err(|_| LogError::Io)?;
171 }
172 Ok(())
173 }
174}
175
176impl LogSink for FileSink {
177 fn emit(&self, event: &LogEvent) -> Result<(), LogError> {
178 let _guard = self.lock.lock();
179 let line = serde_json::to_vec(event).map_err(|_| LogError::Serialization)?;
180 let line_bytes = line.len().saturating_add(1);
181 if u64::try_from(line_bytes).map_err(|_| LogError::Capacity)? > self.config.max_bytes {
182 return Err(LogError::Capacity);
183 }
184 self.rotate(line_bytes, event.timestamp_ms)?;
185 ensure_regular_or_missing(&self.config.path)?;
186 let mut file = OpenOptions::new()
187 .create(true)
188 .append(true)
189 .open(&self.config.path)
190 .map_err(|_| LogError::Io)?;
191 file.write_all(&line)
192 .and_then(|_| file.write_all(b"\n"))
193 .map_err(|_| LogError::Io)?;
194 if self.config.sync_each_write {
195 file.sync_data().map_err(|_| LogError::Io)?;
196 }
197 Ok(())
198 }
199
200 fn name(&self) -> &'static str {
201 "file"
202 }
203}
204
205fn archive_file(
206 source: &std::path::Path,
207 active_path: &std::path::Path,
208 timestamp_ms: u64,
209 config: &FileArchiveConfig,
210) -> Result<(), LogError> {
211 let (year, month) = year_month(timestamp_ms);
212 let year_dir = config.directory.join(format!("{year:04}"));
213 let destination_dir = year_dir.join(format!("{month:02}"));
214 ensure_directory_or_missing(&config.directory)?;
215 ensure_directory_or_missing(&year_dir)?;
216 ensure_directory_or_missing(&destination_dir)?;
217 fs::create_dir_all(&destination_dir).map_err(|_| LogError::Io)?;
218 ensure_directory(&config.directory)?;
219 ensure_directory(&year_dir)?;
220 ensure_directory(&destination_dir)?;
221 prune_archive(&config.directory, config.max_files.saturating_sub(1))?;
222
223 let stem = active_path
224 .file_stem()
225 .and_then(|value| value.to_str())
226 .unwrap_or("log");
227 let extension = active_path
228 .extension()
229 .and_then(|value| value.to_str())
230 .unwrap_or("jsonl");
231 for sequence in 0_u16..=u16::MAX {
232 let destination = destination_dir.join(format!(
233 "{stem}-{timestamp_ms:020}-{sequence:04}.{extension}"
234 ));
235 ensure_regular_or_missing(&destination)?;
236 if !destination.exists() {
237 return fs::rename(source, destination).map_err(|_| LogError::Io);
238 }
239 }
240 Err(LogError::Capacity)
241}
242
243fn prune_archive(root: &std::path::Path, keep: usize) -> Result<(), LogError> {
244 let mut files = Vec::new();
245 if root.exists() {
246 ensure_directory(root)?;
247 for year in fs::read_dir(root).map_err(|_| LogError::Io)? {
248 let year = year.map_err(|_| LogError::Io)?.path();
249 let year_metadata = fs::symlink_metadata(&year).map_err(|_| LogError::Io)?;
250 if !year_metadata.file_type().is_dir() {
251 continue;
252 }
253 for month in fs::read_dir(year).map_err(|_| LogError::Io)? {
254 let month = month.map_err(|_| LogError::Io)?.path();
255 let month_metadata = fs::symlink_metadata(&month).map_err(|_| LogError::Io)?;
256 if !month_metadata.file_type().is_dir() {
257 continue;
258 }
259 for file in fs::read_dir(month).map_err(|_| LogError::Io)? {
260 let file = file.map_err(|_| LogError::Io)?.path();
261 let metadata = fs::symlink_metadata(&file).map_err(|_| LogError::Io)?;
262 if metadata.file_type().is_file() {
263 files.push(file);
264 }
265 }
266 }
267 }
268 }
269 files.sort();
270 let remove = files.len().saturating_sub(keep);
271 for file in files.into_iter().take(remove) {
272 fs::remove_file(file).map_err(|_| LogError::Io)?;
273 }
274 Ok(())
275}
276
277fn year_month(timestamp_ms: u64) -> (i64, i64) {
278 let days = i64::try_from(timestamp_ms / 86_400_000).unwrap_or(i64::MAX);
279 let shifted = days.saturating_add(719_468);
280 let era = shifted.div_euclid(146_097);
281 let day_of_era = shifted - era * 146_097;
282 let year_of_era =
283 (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
284 let mut year = year_of_era + era * 400;
285 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
286 let month_prime = (5 * day_of_year + 2) / 153;
287 let month = month_prime + if month_prime < 10 { 3 } else { -9 };
288 if month <= 2 {
289 year += 1;
290 }
291 (year, month)
292}
293
294pub struct RingBufferSink {
296 state: Mutex<(VecDeque<(LogEvent, usize)>, usize)>,
297 max_events: usize,
298 max_bytes: usize,
299}
300
301impl RingBufferSink {
302 pub fn new(max_events: usize, max_bytes: usize) -> Result<Self, LogError> {
304 if max_events == 0 || max_bytes == 0 {
305 return Err(LogError::Capacity);
306 }
307 Ok(Self {
308 state: Mutex::new((VecDeque::new(), 0)),
309 max_events,
310 max_bytes,
311 })
312 }
313
314 pub fn snapshot(&self) -> Vec<LogEvent> {
316 self.state
317 .lock()
318 .0
319 .iter()
320 .map(|(event, _)| event.clone())
321 .collect()
322 }
323}
324
325impl LogSink for RingBufferSink {
326 fn emit(&self, event: &LogEvent) -> Result<(), LogError> {
327 let size = event.retained_bytes();
328 if size > self.max_bytes {
329 return Err(LogError::Capacity);
330 }
331 let mut state = self.state.lock();
332 while state.0.len() >= self.max_events || state.1.saturating_add(size) > self.max_bytes {
333 let Some((_, old_size)) = state.0.pop_front() else {
334 break;
335 };
336 state.1 = state.1.saturating_sub(old_size);
337 }
338 state.1 = state.1.saturating_add(size);
339 state.0.push_back((event.clone(), size));
340 Ok(())
341 }
342
343 fn name(&self) -> &'static str {
344 "ring"
345 }
346}
347
348#[derive(Debug, Clone)]
350pub struct SensitiveDntSinkConfig {
351 pub path: PathBuf,
353 pub application_id: ApplicationId,
355 pub key_id: KeyId,
357 pub max_bytes: u64,
359 pub max_events: usize,
361 pub retention: u8,
363}
364
365pub struct SensitiveDntSink<P: DntKeyProvider> {
367 config: SensitiveDntSinkConfig,
368 key_provider: P,
369 state: Mutex<(VecDeque<(LogEvent, usize)>, usize)>,
370}
371
372impl<P: DntKeyProvider> SensitiveDntSink<P> {
373 pub fn new(config: SensitiveDntSinkConfig, key_provider: P) -> Result<Self, LogError> {
375 if config.max_bytes == 0 || config.max_events == 0 || config.retention > 32 {
376 return Err(LogError::Capacity);
377 }
378 Ok(Self {
379 config,
380 key_provider,
381 state: Mutex::new((VecDeque::new(), 0)),
382 })
383 }
384}
385
386impl<P: DntKeyProvider> LogSink for SensitiveDntSink<P> {
387 fn emit(&self, event: &LogEvent) -> Result<(), LogError> {
388 let event_size = event.retained_bytes();
389 if u64::try_from(event_size).map_err(|_| LogError::Capacity)? > self.config.max_bytes {
390 return Err(LogError::Capacity);
391 }
392 let mut state = self.state.lock();
393 let max_bytes = usize::try_from(self.config.max_bytes).map_err(|_| LogError::Capacity)?;
394 while state.0.len() >= self.config.max_events
395 || state.1.saturating_add(event_size) > max_bytes
396 {
397 let Some((_, old_size)) = state.0.pop_front() else {
398 break;
399 };
400 state.1 = state.1.saturating_sub(old_size);
401 }
402 state.1 = state.1.saturating_add(event_size);
403 state.0.push_back((event.clone(), event_size));
404 let events = state.0.iter().map(|(stored, _)| stored).collect::<Vec<_>>();
405 let payload = serde_json::to_vec(&events).map_err(|_| LogError::Serialization)?;
406 if u64::try_from(payload.len()).map_err(|_| LogError::Capacity)? > self.config.max_bytes {
407 let _ = state.0.pop_back();
408 state.1 = state.1.saturating_sub(event_size);
409 return Err(LogError::Capacity);
410 }
411 let content =
412 ContentType::new("appcore.log.sensitive").map_err(|_| LogError::Encryption)?;
413 let seal = DntSealOptions {
414 application_id: self.config.application_id.clone(),
415 tenant_id: None,
416 content_type: content.clone(),
417 schema_version: 1,
418 key_id: self.config.key_id.clone(),
419 created_at_ms: event.timestamp_ms,
420 public_metadata:
421 b"sensitivity=sensitive;contains_secrets=true;encrypted=true;format=dnt".to_vec(),
422 encrypted_metadata: Vec::new(),
423 flags: 0,
424 max_payload_bytes: Some(self.config.max_bytes),
425 };
426 let open = DntOpenOptions {
427 application_id: self.config.application_id.clone(),
428 tenant_id: None,
429 content_type: content,
430 max_payload_bytes: Some(self.config.max_bytes),
431 };
432 rotate_sensitive_snapshots(&self.config.path, self.config.retention)?;
433 write_atomic(
434 &self.config.path,
435 &payload,
436 &self.key_provider,
437 &BytesCodec,
438 seal,
439 &open,
440 )
441 .map_err(|_| LogError::Encryption)
442 }
443
444 fn accepts_sensitive(&self) -> bool {
445 true
446 }
447
448 fn name(&self) -> &'static str {
449 "sensitive_dnt"
450 }
451}
452
453fn rotate_sensitive_snapshots(path: &std::path::Path, retention: u8) -> Result<(), LogError> {
454 ensure_regular_or_missing(path)?;
455 if !path.exists() {
456 return Ok(());
457 }
458 if retention == 0 {
459 fs::remove_file(path).map_err(|_| LogError::Io)?;
460 return Ok(());
461 }
462 let oldest = path.with_extension(format!("dnt.{retention}"));
463 ensure_regular_or_missing(&oldest)?;
464 if oldest.exists() {
465 fs::remove_file(&oldest).map_err(|_| LogError::Io)?;
466 }
467 for index in (1..retention).rev() {
468 let from = path.with_extension(format!("dnt.{index}"));
469 let to = path.with_extension(format!("dnt.{}", index + 1));
470 ensure_regular_or_missing(&from)?;
471 ensure_regular_or_missing(&to)?;
472 if from.exists() {
473 fs::rename(from, to).map_err(|_| LogError::Io)?;
474 }
475 }
476 fs::rename(path, path.with_extension("dnt.1")).map_err(|_| LogError::Io)
477}
478
479fn ensure_regular_or_missing(path: &std::path::Path) -> Result<(), LogError> {
480 match fs::symlink_metadata(path) {
481 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.file_type().is_file() => {
482 Err(LogError::Io)
483 }
484 Ok(_) => Ok(()),
485 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
486 Err(_) => Err(LogError::Io),
487 }
488}
489
490fn ensure_directory_or_missing(path: &std::path::Path) -> Result<(), LogError> {
491 match fs::symlink_metadata(path) {
492 Ok(metadata) if metadata.file_type().is_dir() => Ok(()),
493 Ok(_) => Err(LogError::Io),
494 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
495 Err(_) => Err(LogError::Io),
496 }
497}
498
499fn ensure_directory(path: &std::path::Path) -> Result<(), LogError> {
500 let metadata = fs::symlink_metadata(path).map_err(|_| LogError::Io)?;
501 if metadata.file_type().is_dir() {
502 Ok(())
503 } else {
504 Err(LogError::Io)
505 }
506}