icydb_core/obs/metrics/
mod.rs

1//! Runtime metrics are update-only by contract.
2//! Query-side instrumentation is intentionally not surfaced by `report`, so
3//! query metrics are non-existent by design under IC query semantics.
4
5use candid::CandidType;
6use canic_cdk::utils::time::now_millis;
7use serde::{Deserialize, Serialize};
8use std::{cell::RefCell, cmp::Ordering, collections::BTreeMap};
9
10///
11/// EventState
12///
13
14#[derive(CandidType, Clone, Debug, Deserialize, Serialize)]
15pub struct EventState {
16    pub ops: EventOps,
17    pub perf: EventPerf,
18    pub entities: BTreeMap<String, EntityCounters>,
19    pub since_ms: u64,
20}
21
22impl Default for EventState {
23    fn default() -> Self {
24        Self {
25            ops: EventOps::default(),
26            perf: EventPerf::default(),
27            entities: BTreeMap::new(),
28            since_ms: now_millis(),
29        }
30    }
31}
32
33///
34/// EventOps
35///
36
37/// Call counters are execution attempts; errors still increment them.
38/// Row counters reflect rows touched after execution, not requested rows.
39#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
40pub struct EventOps {
41    // Executor entrypoints
42    pub load_calls: u64,
43    pub exists_calls: u64,
44    pub save_calls: u64,
45    pub delete_calls: u64,
46
47    // Planner kinds
48    pub plan_index: u64,
49    pub plan_keys: u64,
50    pub plan_range: u64,
51    pub plan_full_scan: u64,
52
53    // Rows touched
54    pub rows_loaded: u64,
55    pub rows_scanned: u64,
56    pub rows_deleted: u64,
57
58    // Index maintenance
59    pub index_inserts: u64,
60    pub index_removes: u64,
61    pub unique_violations: u64,
62}
63
64///
65/// EntityCounters
66///
67
68#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
69pub struct EntityCounters {
70    pub load_calls: u64,
71    pub exists_calls: u64,
72    pub save_calls: u64,
73    pub delete_calls: u64,
74    pub rows_loaded: u64,
75    pub rows_scanned: u64,
76    pub rows_deleted: u64,
77    pub index_inserts: u64,
78    pub index_removes: u64,
79    pub unique_violations: u64,
80}
81
82///
83/// EventPerf
84///
85
86/// Instruction deltas are pressure indicators (validation + planning + execution),
87/// not latency measurements.
88#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
89pub struct EventPerf {
90    // Instruction totals per executor (ic_cdk::api::performance_counter(1))
91    pub load_inst_total: u128,
92    pub save_inst_total: u128,
93    pub delete_inst_total: u128,
94
95    // Maximum observed instruction deltas
96    pub load_inst_max: u64,
97    pub save_inst_max: u64,
98    pub delete_inst_max: u64,
99}
100
101thread_local! {
102    static EVENT_STATE: RefCell<EventState> = RefCell::new(EventState::default());
103}
104
105/// Borrow metrics immutably.
106pub(crate) fn with_state<R>(f: impl FnOnce(&EventState) -> R) -> R {
107    EVENT_STATE.with(|m| f(&m.borrow()))
108}
109
110/// Borrow metrics mutably.
111pub(crate) fn with_state_mut<R>(f: impl FnOnce(&mut EventState) -> R) -> R {
112    EVENT_STATE.with(|m| f(&mut m.borrow_mut()))
113}
114
115/// Reset all counters (useful in tests).
116pub fn reset() {
117    with_state_mut(|m| *m = EventState::default());
118}
119
120/// Reset all event state: counters, perf, and serialize counters.
121pub fn reset_all() {
122    reset();
123}
124
125/// Accumulate instruction counts and track a max.
126#[allow(clippy::missing_const_for_fn)]
127pub fn add_instructions(total: &mut u128, max: &mut u64, delta_inst: u64) {
128    *total = total.saturating_add(u128::from(delta_inst));
129    if delta_inst > *max {
130        *max = delta_inst;
131    }
132}
133
134///
135/// EventReport
136/// Event/counter report; storage snapshot types live in snapshot/storage modules.
137///
138
139#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
140pub struct EventReport {
141    /// Ephemeral runtime counters since `since_ms`.
142    pub counters: Option<EventState>,
143    /// Per-entity ephemeral counters and averages.
144    pub entity_counters: Vec<EntitySummary>,
145}
146
147///
148/// EntitySummary
149///
150
151#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
152pub struct EntitySummary {
153    pub path: String,
154    pub load_calls: u64,
155    pub exists_calls: u64,
156    pub delete_calls: u64,
157    pub rows_loaded: u64,
158    pub rows_scanned: u64,
159    pub rows_deleted: u64,
160    pub avg_rows_per_load: f64,
161    pub avg_rows_scanned_per_load: f64,
162    pub avg_rows_per_delete: f64,
163    pub index_inserts: u64,
164    pub index_removes: u64,
165    pub unique_violations: u64,
166}
167
168///
169/// EventSelect
170/// Select which parts of the metrics report to include.
171///
172
173#[derive(CandidType, Clone, Copy, Debug, Deserialize, Serialize)]
174#[allow(clippy::struct_excessive_bools)]
175pub struct EventSelect {
176    pub data: bool,
177    pub index: bool,
178    pub counters: bool,
179    pub entities: bool,
180}
181
182impl EventSelect {
183    #[must_use]
184    pub const fn all() -> Self {
185        Self {
186            data: true,
187            index: true,
188            counters: true,
189            entities: true,
190        }
191    }
192}
193
194impl Default for EventSelect {
195    fn default() -> Self {
196        Self::all()
197    }
198}
199
200/// Build a metrics report by inspecting in-memory counters only.
201#[must_use]
202#[allow(clippy::cast_precision_loss)]
203pub fn report() -> EventReport {
204    let snap = with_state(Clone::clone);
205
206    let mut entity_counters: Vec<EntitySummary> = Vec::new();
207    for (path, ops) in &snap.entities {
208        let avg_load = if ops.load_calls > 0 {
209            ops.rows_loaded as f64 / ops.load_calls as f64
210        } else {
211            0.0
212        };
213        let avg_scanned = if ops.load_calls > 0 {
214            ops.rows_scanned as f64 / ops.load_calls as f64
215        } else {
216            0.0
217        };
218        let avg_delete = if ops.delete_calls > 0 {
219            ops.rows_deleted as f64 / ops.delete_calls as f64
220        } else {
221            0.0
222        };
223
224        entity_counters.push(EntitySummary {
225            path: path.clone(),
226            load_calls: ops.load_calls,
227            exists_calls: ops.exists_calls,
228            delete_calls: ops.delete_calls,
229            rows_loaded: ops.rows_loaded,
230            rows_scanned: ops.rows_scanned,
231            rows_deleted: ops.rows_deleted,
232            avg_rows_per_load: avg_load,
233            avg_rows_scanned_per_load: avg_scanned,
234            avg_rows_per_delete: avg_delete,
235            index_inserts: ops.index_inserts,
236            index_removes: ops.index_removes,
237            unique_violations: ops.unique_violations,
238        });
239    }
240
241    entity_counters.sort_by(|a, b| {
242        match b
243            .avg_rows_per_load
244            .partial_cmp(&a.avg_rows_per_load)
245            .unwrap_or(Ordering::Equal)
246        {
247            Ordering::Equal => match b.rows_loaded.cmp(&a.rows_loaded) {
248                Ordering::Equal => a.path.cmp(&b.path),
249                other => other,
250            },
251            other => other,
252        }
253    });
254
255    EventReport {
256        counters: Some(snap),
257        entity_counters,
258    }
259}
260
261///
262/// TESTS
263///
264
265#[cfg(test)]
266#[allow(clippy::float_cmp)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn reset_all_clears_state() {
272        with_state_mut(|m| {
273            m.ops.load_calls = 3;
274            m.ops.index_inserts = 2;
275            m.perf.save_inst_max = 9;
276            m.entities.insert(
277                "alpha".to_string(),
278                EntityCounters {
279                    load_calls: 1,
280                    ..Default::default()
281                },
282            );
283        });
284
285        reset_all();
286
287        with_state(|m| {
288            assert_eq!(m.ops.load_calls, 0);
289            assert_eq!(m.ops.index_inserts, 0);
290            assert_eq!(m.perf.save_inst_max, 0);
291            assert!(m.entities.is_empty());
292        });
293    }
294
295    #[test]
296    fn report_sorts_entities_by_average_rows() {
297        reset_all();
298        with_state_mut(|m| {
299            m.entities.insert(
300                "alpha".to_string(),
301                EntityCounters {
302                    load_calls: 2,
303                    rows_loaded: 6,
304                    ..Default::default()
305                },
306            );
307            m.entities.insert(
308                "beta".to_string(),
309                EntityCounters {
310                    load_calls: 1,
311                    rows_loaded: 5,
312                    ..Default::default()
313                },
314            );
315            m.entities.insert(
316                "gamma".to_string(),
317                EntityCounters {
318                    load_calls: 2,
319                    rows_loaded: 6,
320                    ..Default::default()
321                },
322            );
323        });
324
325        let report = report();
326        let paths: Vec<_> = report
327            .entity_counters
328            .iter()
329            .map(|e| e.path.as_str())
330            .collect();
331
332        // Order by avg rows per load desc, then rows_loaded desc, then path asc.
333        assert_eq!(paths, ["beta", "alpha", "gamma"]);
334        assert_eq!(report.entity_counters[0].avg_rows_per_load, 5.0);
335        assert_eq!(report.entity_counters[1].avg_rows_per_load, 3.0);
336        assert_eq!(report.entity_counters[2].avg_rows_per_load, 3.0);
337    }
338}