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    utils::{
17        case::{Case, Casing},
18        time,
19    },
20};
21
22use candid::CandidType;
23use serde::{Deserialize, Serialize};
24use std::cell::RefCell;
25
26//
27// Stable Log Storage (ic-stable-structures)
28//
29
30type StableLogStorage =
31    StableLogImpl<LogEntry, VirtualMemory<DefaultMemoryImpl>, VirtualMemory<DefaultMemoryImpl>>;
32
33// Marker structs for ic_memory! macro
34struct LogIndexMemory;
35struct LogDataMemory;
36
37fn create_log() -> StableLogStorage {
38    StableLogImpl::new(
39        ic_memory!(LogIndexMemory, LOG_INDEX_ID),
40        ic_memory!(LogDataMemory, LOG_DATA_ID),
41    )
42}
43
44eager_static! {
45    static LOG: RefCell<StableLogStorage> = RefCell::new(create_log());
46}
47
48// Small helpers for readability
49fn with_log<R>(f: impl FnOnce(&StableLogStorage) -> R) -> R {
50    LOG.with_borrow(|l| f(l))
51}
52
53fn with_log_mut<R>(f: impl FnOnce(&mut StableLogStorage) -> R) -> R {
54    LOG.with_borrow_mut(|l| f(l))
55}
56
57pub(crate) fn log_config() -> LogConfig {
58    Config::try_get().map(|c| c.log.clone()).unwrap_or_default()
59}
60
61///
62/// LogError
63/// it's ok to have errors in this model-layer struct as logs have more
64/// error cases than B-Tree maps
65///
66
67#[derive(Debug, ThisError)]
68pub enum LogError {
69    #[error("log write failed: current_size={current_size}, delta={delta}")]
70    WriteFailed { current_size: u64, delta: u64 },
71}
72
73impl From<WriteError> for LogError {
74    fn from(err: WriteError) -> Self {
75        match err {
76            WriteError::GrowFailed {
77                current_size,
78                delta,
79            } => Self::WriteFailed {
80                current_size,
81                delta,
82            },
83        }
84    }
85}
86
87impl From<LogError> for Error {
88    fn from(err: LogError) -> Self {
89        MemoryError::from(err).into()
90    }
91}
92
93///
94/// LogEntry
95///
96
97#[derive(CandidType, Clone, Debug, Deserialize, Serialize)]
98pub struct LogEntry {
99    pub crate_name: String,
100    pub created_at: u64,
101    pub level: Level,
102    pub topic: Option<String>,
103    pub message: String,
104}
105
106impl LogEntry {
107    pub fn new(crate_name: &str, level: Level, topic: Option<&str>, msg: &str) -> Self {
108        Self {
109            crate_name: crate_name.to_string(),
110            created_at: time::now_secs(),
111            level,
112            topic: topic.map(ToString::to_string),
113            message: msg.to_string(),
114        }
115    }
116}
117
118impl_storable_unbounded!(LogEntry);
119
120///
121/// StableLog
122///
123
124pub(crate) struct StableLog;
125
126impl StableLog {
127    // -------- Append --------
128
129    pub fn append<T, M>(
130        crate_name: &str,
131        topic: Option<T>,
132        level: Level,
133        message: M,
134    ) -> Result<u64, Error>
135    where
136        T: ToString,
137        M: AsRef<str>,
138    {
139        let topic_normalized = Self::normalize_topic(topic);
140        let entry = LogEntry::new(
141            crate_name,
142            level,
143            topic_normalized.as_deref(),
144            message.as_ref(),
145        );
146
147        Self::append_entry(entry)
148    }
149
150    pub fn append_entry(entry: LogEntry) -> Result<u64, Error> {
151        let cfg = log_config();
152
153        if cfg.max_entries == 0 {
154            return Ok(0);
155        }
156
157        with_log(|log| log.append(&entry))
158            .map_err(LogError::from)
159            .map_err(Error::from)
160    }
161
162    // -------- Helper -----------
163
164    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            // Collect entire filtered list IN ORDER (once)
183            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            // Slice the requested window
189            let entries = items.into_iter().skip(offset).take(limit).collect();
190
191            (entries, total)
192        })
193    }
194}
195
196// apply_retention
197// currently using the local config
198pub(crate) fn apply_retention() -> Result<(), Error> {
199    let cfg = log_config();
200
201    if cfg.max_entries == 0 {
202        with_log_mut(|log| *log = create_log());
203        return Ok(());
204    }
205
206    let now = time::now_secs();
207    let max_entries = cfg.max_entries as usize;
208
209    // Load all entries once
210    let mut retained: Vec<LogEntry> = with_log(|log| log.iter().collect());
211
212    // Age filter (seconds)
213    if let Some(age) = cfg.max_age_secs {
214        retained.retain(|e| now.saturating_sub(e.created_at) <= age);
215    }
216
217    // Count filter
218    if retained.len() > max_entries {
219        let drop = retained.len() - max_entries;
220        retained.drain(0..drop);
221    }
222
223    // Detect if unchanged — skip rewrite
224    let original_len = with_log(|log| log.len() as usize);
225    if retained.len() == original_len {
226        return Ok(());
227    }
228
229    // Rewrite
230    with_log_mut(|log| *log = create_log());
231    for entry in retained {
232        with_log(|log| log.append(&entry))
233            .map_err(LogError::from)
234            .map_err(Error::from)?;
235    }
236
237    Ok(())
238}
239
240fn iter_filtered<'a>(
241    log: &'a StableLogStorage,
242    crate_name: Option<&'a str>, // this is optional
243    topic: Option<&'a str>,      // optional
244    min_level: Option<Level>,    // optional
245) -> impl Iterator<Item = (usize, LogEntry)> + 'a {
246    log.iter().enumerate().filter(move |(_, e)| {
247        crate_name.is_none_or(|name| e.crate_name == name)
248            && topic.is_none_or(|t| e.topic.as_deref() == Some(t))
249            && min_level.is_none_or(|lvl| e.level >= lvl)
250    })
251}