canic_core/model/memory/
log.rs

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