1#![allow(clippy::cast_possible_truncation)]
2use crate::{
3 Error, ThisError,
4 cdk::structures::{
5 DefaultMemoryImpl,
6 log::{Log as StableLogImpl, WriteError},
7 memory::VirtualMemory,
8 },
9 config::{Config, schema::LogConfig},
10 eager_static, ic_memory, impl_storable_unbounded,
11 log::Level,
12 model::memory::{
13 MemoryError,
14 id::log::{LOG_DATA_ID, LOG_INDEX_ID},
15 },
16 types::PageRequest,
17 utils::{
18 case::{Case, Casing},
19 time,
20 },
21};
22
23use candid::CandidType;
24use serde::{Deserialize, Serialize};
25use std::cell::RefCell;
26
27type StableLogStorage =
32 StableLogImpl<LogEntry, VirtualMemory<DefaultMemoryImpl>, VirtualMemory<DefaultMemoryImpl>>;
33
34struct LogIndexMemory;
36struct LogDataMemory;
37
38fn create_log() -> StableLogStorage {
39 StableLogImpl::new(
40 ic_memory!(LogIndexMemory, LOG_INDEX_ID),
41 ic_memory!(LogDataMemory, LOG_DATA_ID),
42 )
43}
44
45eager_static! {
46 static LOG: RefCell<StableLogStorage> = RefCell::new(create_log());
47}
48
49fn with_log<R>(f: impl FnOnce(&StableLogStorage) -> R) -> R {
51 LOG.with_borrow(|l| f(l))
52}
53
54fn with_log_mut<R>(f: impl FnOnce(&mut StableLogStorage) -> R) -> R {
55 LOG.with_borrow_mut(|l| f(l))
56}
57
58pub(crate) fn log_config() -> LogConfig {
59 Config::try_get().map(|c| c.log.clone()).unwrap_or_default()
60}
61
62#[derive(Debug, ThisError)]
69pub enum LogError {
70 #[error("log write failed: current_size={current_size}, delta={delta}")]
71 WriteFailed { current_size: u64, delta: u64 },
72}
73
74impl From<WriteError> for LogError {
75 fn from(err: WriteError) -> Self {
76 match err {
77 WriteError::GrowFailed {
78 current_size,
79 delta,
80 } => Self::WriteFailed {
81 current_size,
82 delta,
83 },
84 }
85 }
86}
87
88impl From<LogError> for Error {
89 fn from(err: LogError) -> Self {
90 MemoryError::from(err).into()
91 }
92}
93
94#[derive(CandidType, Clone, Debug, Deserialize, Serialize)]
99pub struct LogEntry {
100 pub crate_name: String,
101 pub created_at: u64,
102 pub level: Level,
103 pub topic: Option<String>,
104 pub message: String,
105}
106
107impl LogEntry {
108 pub fn new(crate_name: &str, level: Level, topic: Option<&str>, msg: &str) -> Self {
109 Self {
110 crate_name: crate_name.to_string(),
111 created_at: time::now_secs(),
112 level,
113 topic: topic.map(ToString::to_string),
114 message: msg.to_string(),
115 }
116 }
117}
118
119impl_storable_unbounded!(LogEntry);
120
121pub(crate) struct StableLog;
126
127impl StableLog {
128 pub fn append<T, M>(
131 crate_name: &str,
132 topic: Option<T>,
133 level: Level,
134 message: M,
135 ) -> Result<u64, Error>
136 where
137 T: ToString,
138 M: AsRef<str>,
139 {
140 let topic_normalized = Self::normalize_topic(topic);
141 let entry = LogEntry::new(
142 crate_name,
143 level,
144 topic_normalized.as_deref(),
145 message.as_ref(),
146 );
147
148 Self::append_entry(entry)
149 }
150
151 pub fn append_entry(entry: LogEntry) -> Result<u64, Error> {
152 let cfg = log_config();
153
154 if cfg.max_entries == 0 {
155 return Ok(0);
156 }
157
158 with_log(|log| log.append(&entry))
159 .map_err(LogError::from)
160 .map_err(Error::from)
161 }
162
163 fn normalize_topic<T: ToString>(topic: Option<T>) -> Option<String> {
166 topic.as_ref().map(|t| t.to_string().to_case(Case::Snake))
167 }
168
169 #[must_use]
170 pub fn entries_page_filtered(
171 crate_name: Option<&str>,
172 topic: Option<&str>,
173 min_level: Option<Level>,
174 request: PageRequest,
175 ) -> (Vec<(usize, LogEntry)>, u64) {
176 let request = request.clamped();
177 let offset = usize::try_from(request.offset).unwrap_or(usize::MAX);
178 let limit = usize::try_from(request.limit).unwrap_or(usize::MAX);
179 let topic_norm: Option<String> = Self::normalize_topic(topic);
180 let topic_norm = topic_norm.as_deref();
181
182 with_log(|log| {
183 let items: Vec<(usize, LogEntry)> =
185 iter_filtered(log, crate_name, topic_norm, min_level).collect();
186
187 let total = items.len() as u64;
188
189 let entries = items.into_iter().skip(offset).take(limit).collect();
191
192 (entries, total)
193 })
194 }
195}
196
197pub(crate) fn apply_retention() -> Result<(), Error> {
200 let cfg = log_config();
201
202 if cfg.max_entries == 0 {
203 with_log_mut(|log| *log = create_log());
204 return Ok(());
205 }
206
207 let now = time::now_secs();
208 let max_entries = cfg.max_entries as usize;
209
210 let mut retained: Vec<LogEntry> = with_log(|log| log.iter().collect());
212
213 if let Some(age) = cfg.max_age_secs {
215 retained.retain(|e| now.saturating_sub(e.created_at) <= age);
216 }
217
218 if retained.len() > max_entries {
220 let drop = retained.len() - max_entries;
221 retained.drain(0..drop);
222 }
223
224 let original_len = with_log(|log| log.len() as usize);
226 if retained.len() == original_len {
227 return Ok(());
228 }
229
230 with_log_mut(|log| *log = create_log());
232 for entry in retained {
233 with_log(|log| log.append(&entry))
234 .map_err(LogError::from)
235 .map_err(Error::from)?;
236 }
237
238 Ok(())
239}
240
241fn iter_filtered<'a>(
242 log: &'a StableLogStorage,
243 crate_name: Option<&'a str>, topic: Option<&'a str>, min_level: Option<Level>, ) -> impl Iterator<Item = (usize, LogEntry)> + 'a {
247 log.iter().enumerate().filter(move |(_, e)| {
248 crate_name.is_none_or(|name| e.crate_name == name)
249 && topic.is_none_or(|t| e.topic.as_deref() == Some(t))
250 && min_level.is_none_or(|lvl| e.level >= lvl)
251 })
252}