icydb_core/obs/metrics/
mod.rs

1use crate::traits::EntityKind;
2use candid::CandidType;
3use canic::{cdk::api::performance_counter, utils::time};
4use serde::{Deserialize, Serialize};
5use std::cmp::Ordering;
6use std::{cell::RefCell, collections::BTreeMap, marker::PhantomData};
7
8///
9/// Metrics
10/// Ephemeral, in-memory counters and simple perf totals for operations.
11///
12
13#[derive(CandidType, Clone, Debug, Deserialize, Serialize)]
14pub struct EventState {
15    pub ops: EventOps,
16    pub perf: EventPerf,
17    pub entities: BTreeMap<String, EntityCounters>,
18    pub since_ms: u64,
19}
20
21impl Default for EventState {
22    fn default() -> Self {
23        Self {
24            ops: EventOps::default(),
25            perf: EventPerf::default(),
26            entities: BTreeMap::new(),
27            since_ms: time::now_millis(),
28        }
29    }
30}
31
32///
33/// EventOps
34///
35
36#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
37pub struct EventOps {
38    // Executor entrypoints
39    pub load_calls: u64,
40    pub save_calls: u64,
41    pub delete_calls: u64,
42
43    // Planner kinds
44    pub plan_index: u64,
45    pub plan_keys: u64,
46    pub plan_range: u64,
47
48    // Rows touched
49    pub rows_loaded: u64,
50    pub rows_deleted: u64,
51
52    // Index maintenance
53    pub index_inserts: u64,
54    pub index_removes: u64,
55    pub unique_violations: u64,
56}
57
58///
59/// EntityOps
60///
61
62#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
63pub struct EntityCounters {
64    pub load_calls: u64,
65    pub save_calls: u64,
66    pub delete_calls: u64,
67    pub rows_loaded: u64,
68    pub rows_deleted: u64,
69    pub index_inserts: u64,
70    pub index_removes: u64,
71    pub unique_violations: u64,
72}
73
74///
75/// Perf
76///
77
78#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
79pub struct EventPerf {
80    // Instruction totals per executor (ic_cdk::api::performance_counter(1))
81    pub load_inst_total: u128,
82    pub save_inst_total: u128,
83    pub delete_inst_total: u128,
84
85    // Maximum observed instruction deltas
86    pub load_inst_max: u64,
87    pub save_inst_max: u64,
88    pub delete_inst_max: u64,
89}
90
91thread_local! {
92    static EVENT_STATE: RefCell<EventState> = RefCell::new(EventState::default());
93}
94
95/// Borrow metrics immutably.
96pub(crate) fn with_state<R>(f: impl FnOnce(&EventState) -> R) -> R {
97    EVENT_STATE.with(|m| f(&m.borrow()))
98}
99
100/// Borrow metrics mutably.
101pub(crate) fn with_state_mut<R>(f: impl FnOnce(&mut EventState) -> R) -> R {
102    EVENT_STATE.with(|m| f(&mut m.borrow_mut()))
103}
104
105/// Reset all counters (useful in tests).
106pub fn reset() {
107    with_state_mut(|m| *m = EventState::default());
108}
109
110/// Reset all event state: counters, perf, and serialize counters.
111pub fn reset_all() {
112    reset();
113}
114
115/// Accumulate instruction counts and track a max.
116#[allow(clippy::missing_const_for_fn)]
117pub fn add_instructions(total: &mut u128, max: &mut u64, delta_inst: u64) {
118    *total = total.saturating_add(u128::from(delta_inst));
119    if delta_inst > *max {
120        *max = delta_inst;
121    }
122}
123
124///
125/// ExecKind
126///
127
128#[derive(Clone, Copy, Debug)]
129pub enum ExecKind {
130    Load,
131    Save,
132    Delete,
133}
134
135/// Begin an executor timing span and increment call counters.
136/// Returns the start instruction counter value.
137#[must_use]
138pub(crate) fn exec_start(kind: ExecKind) -> u64 {
139    with_state_mut(|m| match kind {
140        ExecKind::Load => m.ops.load_calls = m.ops.load_calls.saturating_add(1),
141        ExecKind::Save => m.ops.save_calls = m.ops.save_calls.saturating_add(1),
142        ExecKind::Delete => m.ops.delete_calls = m.ops.delete_calls.saturating_add(1),
143    });
144
145    // Instruction counter (counter_type = 1) is per-message and monotonic.
146    performance_counter(1)
147}
148
149/// Finish an executor timing span and aggregate instruction deltas and row counters.
150pub(crate) fn exec_finish(kind: ExecKind, start_inst: u64, rows_touched: u64) {
151    let now = performance_counter(1);
152    let delta = now.saturating_sub(start_inst);
153
154    with_state_mut(|m| match kind {
155        ExecKind::Load => {
156            m.ops.rows_loaded = m.ops.rows_loaded.saturating_add(rows_touched);
157            add_instructions(
158                &mut m.perf.load_inst_total,
159                &mut m.perf.load_inst_max,
160                delta,
161            );
162        }
163        ExecKind::Save => {
164            add_instructions(
165                &mut m.perf.save_inst_total,
166                &mut m.perf.save_inst_max,
167                delta,
168            );
169        }
170        ExecKind::Delete => {
171            m.ops.rows_deleted = m.ops.rows_deleted.saturating_add(rows_touched);
172            add_instructions(
173                &mut m.perf.delete_inst_total,
174                &mut m.perf.delete_inst_max,
175                delta,
176            );
177        }
178    });
179}
180
181/// Per-entity variants using EntityKind::PATH
182#[must_use]
183pub(crate) fn exec_start_for<E>(kind: ExecKind) -> u64
184where
185    E: EntityKind,
186{
187    let start = exec_start(kind);
188    with_state_mut(|m| {
189        let entry = m.entities.entry(E::PATH.to_string()).or_default();
190        match kind {
191            ExecKind::Load => entry.load_calls = entry.load_calls.saturating_add(1),
192            ExecKind::Save => entry.save_calls = entry.save_calls.saturating_add(1),
193            ExecKind::Delete => entry.delete_calls = entry.delete_calls.saturating_add(1),
194        }
195    });
196    start
197}
198
199/// Finish a per-entity span and accumulate rows touched.
200pub(crate) fn exec_finish_for<E>(kind: ExecKind, start_inst: u64, rows_touched: u64)
201where
202    E: EntityKind,
203{
204    exec_finish(kind, start_inst, rows_touched);
205    with_state_mut(|m| {
206        let entry = m.entities.entry(E::PATH.to_string()).or_default();
207        match kind {
208            ExecKind::Load => entry.rows_loaded = entry.rows_loaded.saturating_add(rows_touched),
209            ExecKind::Delete => {
210                entry.rows_deleted = entry.rows_deleted.saturating_add(rows_touched);
211            }
212            ExecKind::Save => {}
213        }
214    });
215}
216
217///
218/// Span
219/// RAII guard to simplify metrics instrumentation
220///
221
222pub(crate) struct Span<E: EntityKind> {
223    kind: ExecKind,
224    start: u64,
225    rows: u64,
226    finished: bool,
227    _marker: PhantomData<E>,
228}
229
230impl<E: EntityKind> Span<E> {
231    #[must_use]
232    /// Start a metrics span for a specific entity and executor kind.
233    pub(crate) fn new(kind: ExecKind) -> Self {
234        Self {
235            kind,
236            start: exec_start_for::<E>(kind),
237            rows: 0,
238            finished: false,
239            _marker: PhantomData,
240        }
241    }
242
243    pub(crate) const fn set_rows(&mut self, rows: u64) {
244        self.rows = rows;
245    }
246
247    #[expect(dead_code)]
248    /// Increment the recorded row count.
249    pub(crate) const fn add_rows(&mut self, rows: u64) {
250        self.rows = self.rows.saturating_add(rows);
251    }
252
253    #[expect(dead_code)]
254    /// Finish the span early (also happens on Drop).
255    pub(crate) fn finish(mut self) {
256        if !self.finished {
257            exec_finish_for::<E>(self.kind, self.start, self.rows);
258            self.finished = true;
259        }
260    }
261}
262
263impl<E: EntityKind> Drop for Span<E> {
264    fn drop(&mut self) {
265        if !self.finished {
266            exec_finish_for::<E>(self.kind, self.start, self.rows);
267            self.finished = true;
268        }
269    }
270}
271
272///
273/// EventReport
274/// Event/counter report; storage snapshot types live in snapshot/storage modules.
275///
276
277#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
278pub struct EventReport {
279    /// Ephemeral runtime counters since `since_ms`.
280    pub counters: Option<EventState>,
281    /// Per-entity ephemeral counters and averages.
282    pub entity_counters: Vec<EntitySummary>,
283}
284
285///
286/// EntitySummary
287///
288
289#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
290pub struct EntitySummary {
291    pub path: String,
292    pub load_calls: u64,
293    pub delete_calls: u64,
294    pub rows_loaded: u64,
295    pub rows_deleted: u64,
296    pub avg_rows_per_load: f64,
297    pub avg_rows_per_delete: f64,
298    pub index_inserts: u64,
299    pub index_removes: u64,
300    pub unique_violations: u64,
301}
302
303/// Increment unique-violation counters globally and for a specific entity type.
304pub(crate) fn record_unique_violation_for<E>(m: &mut EventState)
305where
306    E: crate::traits::EntityKind,
307{
308    m.ops.unique_violations = m.ops.unique_violations.saturating_add(1);
309    let entry = m.entities.entry(E::PATH.to_string()).or_default();
310    entry.unique_violations = entry.unique_violations.saturating_add(1);
311}
312
313///
314/// EventSelect
315/// Select which parts of the metrics report to include.
316///
317
318#[derive(CandidType, Clone, Copy, Debug, Deserialize, Serialize)]
319#[allow(clippy::struct_excessive_bools)]
320pub struct EventSelect {
321    pub data: bool,
322    pub index: bool,
323    pub counters: bool,
324    pub entities: bool,
325}
326
327impl EventSelect {
328    #[must_use]
329    pub const fn all() -> Self {
330        Self {
331            data: true,
332            index: true,
333            counters: true,
334            entities: true,
335        }
336    }
337}
338
339impl Default for EventSelect {
340    fn default() -> Self {
341        Self::all()
342    }
343}
344
345/// Build a metrics report by inspecting in-memory counters only.
346#[must_use]
347#[allow(clippy::cast_precision_loss)]
348pub fn report() -> EventReport {
349    let snap = with_state(Clone::clone);
350
351    let mut entity_counters: Vec<EntitySummary> = Vec::new();
352    for (path, ops) in &snap.entities {
353        let avg_load = if ops.load_calls > 0 {
354            ops.rows_loaded as f64 / ops.load_calls as f64
355        } else {
356            0.0
357        };
358        let avg_delete = if ops.delete_calls > 0 {
359            ops.rows_deleted as f64 / ops.delete_calls as f64
360        } else {
361            0.0
362        };
363
364        entity_counters.push(EntitySummary {
365            path: path.clone(),
366            load_calls: ops.load_calls,
367            delete_calls: ops.delete_calls,
368            rows_loaded: ops.rows_loaded,
369            rows_deleted: ops.rows_deleted,
370            avg_rows_per_load: avg_load,
371            avg_rows_per_delete: avg_delete,
372            index_inserts: ops.index_inserts,
373            index_removes: ops.index_removes,
374            unique_violations: ops.unique_violations,
375        });
376    }
377
378    entity_counters.sort_by(|a, b| {
379        match b
380            .avg_rows_per_load
381            .partial_cmp(&a.avg_rows_per_load)
382            .unwrap_or(Ordering::Equal)
383        {
384            Ordering::Equal => match b.rows_loaded.cmp(&a.rows_loaded) {
385                Ordering::Equal => a.path.cmp(&b.path),
386                other => other,
387            },
388            other => other,
389        }
390    });
391
392    EventReport {
393        counters: Some(snap),
394        entity_counters,
395    }
396}
397
398///
399/// TESTS
400///
401
402#[cfg(test)]
403#[allow(clippy::float_cmp)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn reset_all_clears_state() {
409        with_state_mut(|m| {
410            m.ops.load_calls = 3;
411            m.ops.index_inserts = 2;
412            m.perf.save_inst_max = 9;
413            m.entities.insert(
414                "alpha".to_string(),
415                EntityCounters {
416                    load_calls: 1,
417                    ..Default::default()
418                },
419            );
420        });
421
422        reset_all();
423
424        with_state(|m| {
425            assert_eq!(m.ops.load_calls, 0);
426            assert_eq!(m.ops.index_inserts, 0);
427            assert_eq!(m.perf.save_inst_max, 0);
428            assert!(m.entities.is_empty());
429        });
430    }
431
432    #[test]
433    fn report_sorts_entities_by_average_rows() {
434        reset_all();
435        with_state_mut(|m| {
436            m.entities.insert(
437                "alpha".to_string(),
438                EntityCounters {
439                    load_calls: 2,
440                    rows_loaded: 6,
441                    ..Default::default()
442                },
443            );
444            m.entities.insert(
445                "beta".to_string(),
446                EntityCounters {
447                    load_calls: 1,
448                    rows_loaded: 5,
449                    ..Default::default()
450                },
451            );
452            m.entities.insert(
453                "gamma".to_string(),
454                EntityCounters {
455                    load_calls: 2,
456                    rows_loaded: 6,
457                    ..Default::default()
458                },
459            );
460        });
461
462        let report = report();
463        let paths: Vec<_> = report
464            .entity_counters
465            .iter()
466            .map(|e| e.path.as_str())
467            .collect();
468
469        // Order by avg rows per load desc, then rows_loaded desc, then path asc.
470        assert_eq!(paths, ["beta", "alpha", "gamma"]);
471        assert_eq!(report.entity_counters[0].avg_rows_per_load, 5.0);
472        assert_eq!(report.entity_counters[1].avg_rows_per_load, 3.0);
473        assert_eq!(report.entity_counters[2].avg_rows_per_load, 3.0);
474    }
475}