canic_core/model/memory/
log.rs

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
27//
28// Stable Log Storage (ic-stable-structures)
29//
30
31type StableLogStorage =
32    StableLogImpl<LogEntry, VirtualMemory<DefaultMemoryImpl>, VirtualMemory<DefaultMemoryImpl>>;
33
34// Marker structs for ic_memory! macro
35struct 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
49// Small helpers for readability
50fn 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///
63/// LogError
64/// it's ok to have errors in this model-layer struct as logs have more
65/// error cases than B-Tree maps
66///
67
68#[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///
95/// LogEntry
96///
97
98#[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
121///
122/// StableLog
123///
124
125pub(crate) struct StableLog;
126
127impl StableLog {
128    // -------- Append --------
129
130    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    // -------- Helper -----------
164
165    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            // Collect entire filtered list IN ORDER (once)
184            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            // Slice the requested window
190            let entries = items.into_iter().skip(offset).take(limit).collect();
191
192            (entries, total)
193        })
194    }
195}
196
197// apply_retention
198// currently using the local config
199pub(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    // Load all entries once
211    let mut retained: Vec<LogEntry> = with_log(|log| log.iter().collect());
212
213    // Age filter (seconds)
214    if let Some(age) = cfg.max_age_secs {
215        retained.retain(|e| now.saturating_sub(e.created_at) <= age);
216    }
217
218    // Count filter
219    if retained.len() > max_entries {
220        let drop = retained.len() - max_entries;
221        retained.drain(0..drop);
222    }
223
224    // Detect if unchanged — skip rewrite
225    let original_len = with_log(|log| log.len() as usize);
226    if retained.len() == original_len {
227        return Ok(());
228    }
229
230    // Rewrite
231    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>, // this is optional
244    topic: Option<&'a str>,      // optional
245    min_level: Option<Level>,    // optional
246) -> 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}