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