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