Skip to main content

appcore_ops/
log.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: log.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/05/31 13:38:42 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/07/23 23:50:45 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Logging contracts for runtime observability.
12
13use parking_lot::Mutex;
14use std::collections::VecDeque;
15use std::io::Write;
16use std::sync::Arc;
17
18/// Maximum records retained by one in-memory logger.
19pub const MAX_IN_MEMORY_LOG_RECORDS: usize = 4_096;
20/// Absolute aggregate retained-byte ceiling for one in-memory logger.
21pub const MAX_IN_MEMORY_LOG_BYTES: usize = 8 * 1024 * 1024;
22/// Maximum UTF-8 bytes retained in one log target.
23pub const MAX_LOG_TARGET_BYTES: usize = 128;
24const LOG_RECORD_FIXED_BYTES: usize = std::mem::size_of::<LogRecord>()
25    + std::mem::size_of::<Arc<LogRecord>>()
26    + std::mem::size_of::<usize>() * 2;
27
28/// Structured log level.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum LogLevel {
31    /// Fine-grained diagnostic event.
32    Trace,
33    /// Developer diagnostic event.
34    Debug,
35    /// Normal operational event.
36    Info,
37    /// Recoverable or degraded condition.
38    Warn,
39    /// Failed operation.
40    Error,
41}
42
43/// One runtime log record.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct LogRecord {
46    /// Record severity.
47    pub level: LogLevel,
48    /// Stable Runtime subsystem target.
49    pub target: String,
50    /// Non-sensitive message subject to redaction.
51    pub message: String,
52    /// Timestamp in Unix milliseconds.
53    pub timestamp_ms: u64,
54}
55
56/// Contract for runtime log sinks.
57pub trait RuntimeLogger: Send + Sync {
58    /// Emits one structured Runtime log record.
59    fn log(&self, record: LogRecord);
60}
61
62/// Immutable shared view of retained log records, ordered oldest to newest.
63#[derive(Debug, Clone, Default)]
64pub struct LogSnapshot {
65    records: Arc<VecDeque<Arc<LogRecord>>>,
66}
67
68impl LogSnapshot {
69    /// Returns the number of retained records.
70    pub fn len(&self) -> usize {
71        self.records.len()
72    }
73
74    /// Reports whether no records are retained.
75    pub fn is_empty(&self) -> bool {
76        self.records.is_empty()
77    }
78
79    /// Iterates over retained records without cloning them.
80    pub fn iter(&self) -> impl DoubleEndedIterator<Item = &LogRecord> {
81        self.records.iter().map(AsRef::as_ref)
82    }
83
84    /// Iterates over at most the newest `limit` records in chronological order.
85    pub fn recent(&self, limit: usize) -> impl Iterator<Item = &LogRecord> {
86        self.records
87            .iter()
88            .skip(self.records.len().saturating_sub(limit))
89            .map(AsRef::as_ref)
90    }
91}
92
93/// Point-in-time count and retained-memory pressure for an in-memory logger.
94#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
95pub struct InMemoryLogPressure {
96    /// Current retained record count.
97    pub entries: usize,
98    /// Configured record ceiling.
99    pub max_entries: usize,
100    /// Estimated bytes retained by records and owned text.
101    pub used_bytes: usize,
102    /// Highest estimated retained byte count observed since creation.
103    pub peak_bytes: usize,
104    /// Configured aggregate retained-byte ceiling.
105    pub max_bytes: usize,
106    /// Oldest records discarded to enforce count or byte limits.
107    pub evictions: u64,
108    /// Records not retained because one record exceeded the byte ceiling.
109    pub oversized_rejections: u64,
110}
111
112#[derive(Debug)]
113struct LogState {
114    records: Arc<VecDeque<Arc<LogRecord>>>,
115    pressure: InMemoryLogPressure,
116}
117
118/// Minimal stdout logger for local runtime operation.
119#[derive(Debug, Default, Clone, Copy)]
120pub struct StdoutLogger;
121
122impl StdoutLogger {
123    /// Creates a stdout logger.
124    pub fn new() -> Self {
125        Self
126    }
127}
128
129impl RuntimeLogger for StdoutLogger {
130    fn log(&self, record: LogRecord) {
131        let message = appcore_core::redact_text(&record.message);
132        let target = appcore_core::redact_text_with_limit(&record.target, 128);
133        let _ = writeln!(
134            std::io::stdout().lock(),
135            "[{:?}] {} {} {}",
136            record.level,
137            target,
138            message,
139            record.timestamp_ms
140        );
141    }
142}
143
144/// In-memory logger for deterministic tests.
145#[derive(Debug)]
146pub struct InMemoryLogger {
147    state: Mutex<LogState>,
148}
149
150impl InMemoryLogger {
151    /// Creates an empty in-memory logger.
152    pub fn new() -> Self {
153        Self::with_limits(MAX_IN_MEMORY_LOG_RECORDS, MAX_IN_MEMORY_LOG_BYTES)
154    }
155
156    /// Creates a logger under explicit limits clamped to public safety ceilings.
157    pub fn with_limits(max_entries: usize, max_bytes: usize) -> Self {
158        let max_entries = max_entries.clamp(1, MAX_IN_MEMORY_LOG_RECORDS);
159        let max_bytes = max_bytes.clamp(1, MAX_IN_MEMORY_LOG_BYTES);
160        Self {
161            state: Mutex::new(LogState {
162                records: Arc::new(VecDeque::new()),
163                pressure: InMemoryLogPressure {
164                    max_entries,
165                    max_bytes,
166                    ..InMemoryLogPressure::default()
167                },
168            }),
169        }
170    }
171
172    /// Returns the number of retained records.
173    pub fn len(&self) -> usize {
174        self.state.lock().pressure.entries
175    }
176
177    /// Reports whether no records are retained.
178    pub fn is_empty(&self) -> bool {
179        self.len() == 0
180    }
181
182    /// Returns a point-in-time copy of retained records.
183    pub fn records(&self) -> Vec<LogRecord> {
184        self.shared_records().iter().cloned().collect()
185    }
186
187    /// Returns an immutable snapshot without cloning retained records.
188    pub fn shared_records(&self) -> LogSnapshot {
189        LogSnapshot {
190            records: Arc::clone(&self.state.lock().records),
191        }
192    }
193
194    /// Returns current count and retained-memory pressure.
195    pub fn pressure(&self) -> InMemoryLogPressure {
196        self.state.lock().pressure
197    }
198}
199
200impl Default for InMemoryLogger {
201    fn default() -> Self {
202        Self::new()
203    }
204}
205
206impl RuntimeLogger for InMemoryLogger {
207    fn log(&self, record: LogRecord) {
208        let mut record = record;
209        record.message = appcore_core::redact_text(&record.message);
210        record.target = appcore_core::redact_text_with_limit(&record.target, MAX_LOG_TARGET_BYTES);
211        record.message.shrink_to_fit();
212        record.target.shrink_to_fit();
213        let record = Arc::new(record);
214        let retained_bytes = log_record_retained_bytes(&record);
215        let mut state = self.state.lock();
216        if retained_bytes > state.pressure.max_bytes {
217            state.pressure.oversized_rejections =
218                state.pressure.oversized_rejections.saturating_add(1);
219            return;
220        }
221        while state.pressure.entries >= state.pressure.max_entries
222            || state.pressure.used_bytes.saturating_add(retained_bytes) > state.pressure.max_bytes
223        {
224            let Some(removed) = Arc::make_mut(&mut state.records).pop_front() else {
225                break;
226            };
227            state.pressure.entries = state.pressure.entries.saturating_sub(1);
228            state.pressure.used_bytes = state
229                .pressure
230                .used_bytes
231                .saturating_sub(log_record_retained_bytes(&removed));
232            state.pressure.evictions = state.pressure.evictions.saturating_add(1);
233        }
234        Arc::make_mut(&mut state.records).push_back(record);
235        state.pressure.entries = state.pressure.entries.saturating_add(1);
236        state.pressure.used_bytes = state.pressure.used_bytes.saturating_add(retained_bytes);
237        state.pressure.peak_bytes = state.pressure.peak_bytes.max(state.pressure.used_bytes);
238    }
239}
240
241fn log_record_retained_bytes(record: &LogRecord) -> usize {
242    LOG_RECORD_FIXED_BYTES
243        .saturating_add(record.target.capacity())
244        .saturating_add(record.message.capacity())
245}
246
247#[cfg(test)]
248#[path = "log_tests.rs"]
249mod tests;