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};
21use candid::CandidType;
22use serde::{Deserialize, Serialize};
23use std::cell::RefCell;
24
25//
26// Stable Log Storage (ic-stable-structures)
27//
28
29type StableLogStorage =
30    StableLogImpl<LogEntry, VirtualMemory<DefaultMemoryImpl>, VirtualMemory<DefaultMemoryImpl>>;
31
32// Marker structs for ic_memory! macro
33struct 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
47// Small helpers for readability
48fn 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///
61/// LogError
62/// it's ok to have errors in this model-layer struct as logs have more
63/// error cases than B-Tree maps
64///
65
66#[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///
93/// LogEntry
94///
95
96#[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
119///
120/// StableLog
121///
122
123pub(crate) struct StableLog;
124
125impl StableLog {
126    // -------- Append --------
127
128    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    // -------- 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
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    // Load all entries once
206    let mut retained: Vec<LogEntry> = with_log(|log| log.iter().collect());
207
208    // Age filter (seconds)
209    if let Some(age) = cfg.max_age_secs {
210        retained.retain(|e| now.saturating_sub(e.created_at) <= age);
211    }
212
213    // Count filter
214    if retained.len() > max_entries {
215        let drop = retained.len() - max_entries;
216        retained.drain(0..drop);
217    }
218
219    // Detect if unchanged — skip rewrite
220    let original_len = with_log(|log| log.len() as usize);
221    if retained.len() == original_len {
222        return Ok(());
223    }
224
225    // Rewrite
226    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>, // this is optional
239    topic: Option<&'a str>,      // optional
240    min_level: Option<Level>,    // optional
241) -> 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}