icydb_core/obs/metrics/
mod.rs

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