Skip to main content

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 save_calls: u64,
44    pub delete_calls: u64,
45
46    // Planner kinds
47    pub plan_index: u64,
48    pub plan_keys: u64,
49    pub plan_range: u64,
50    pub plan_full_scan: u64,
51
52    // Rows touched
53    pub rows_loaded: u64,
54    pub rows_scanned: u64,
55    pub rows_deleted: u64,
56
57    // Index maintenance
58    pub index_inserts: u64,
59    pub index_removes: u64,
60    pub unique_violations: u64,
61}
62
63///
64/// EntityCounters
65///
66
67#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
68pub struct EntityCounters {
69    pub load_calls: u64,
70    pub save_calls: u64,
71    pub delete_calls: u64,
72    pub rows_loaded: u64,
73    pub rows_scanned: u64,
74    pub rows_deleted: u64,
75    pub index_inserts: u64,
76    pub index_removes: u64,
77    pub unique_violations: u64,
78}
79
80///
81/// EventPerf
82///
83
84/// Instruction deltas are pressure indicators (validation + planning + execution),
85/// not latency measurements.
86#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
87pub struct EventPerf {
88    // Instruction totals per executor (ic_cdk::api::performance_counter(1))
89    pub load_inst_total: u128,
90    pub save_inst_total: u128,
91    pub delete_inst_total: u128,
92
93    // Maximum observed instruction deltas
94    pub load_inst_max: u64,
95    pub save_inst_max: u64,
96    pub delete_inst_max: u64,
97}
98
99thread_local! {
100    static EVENT_STATE: RefCell<EventState> = RefCell::new(EventState::default());
101}
102
103/// Borrow metrics immutably.
104pub(crate) fn with_state<R>(f: impl FnOnce(&EventState) -> R) -> R {
105    EVENT_STATE.with(|m| f(&m.borrow()))
106}
107
108/// Borrow metrics mutably.
109pub(crate) fn with_state_mut<R>(f: impl FnOnce(&mut EventState) -> R) -> R {
110    EVENT_STATE.with(|m| f(&mut m.borrow_mut()))
111}
112
113/// Reset all counters (useful in tests).
114pub fn reset() {
115    with_state_mut(|m| *m = EventState::default());
116}
117
118/// Reset all event state: counters, perf, and serialize counters.
119pub fn reset_all() {
120    reset();
121}
122
123/// Accumulate instruction counts and track a max.
124#[allow(clippy::missing_const_for_fn)]
125pub fn add_instructions(total: &mut u128, max: &mut u64, delta_inst: u64) {
126    *total = total.saturating_add(u128::from(delta_inst));
127    if delta_inst > *max {
128        *max = delta_inst;
129    }
130}
131
132///
133/// EventReport
134/// Event/counter report; storage snapshot types live in snapshot/storage modules.
135///
136
137#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
138pub struct EventReport {
139    /// Ephemeral runtime counters since `since_ms`.
140    pub counters: Option<EventState>,
141    /// Per-entity ephemeral counters and averages.
142    pub entity_counters: Vec<EntitySummary>,
143}
144
145///
146/// EntitySummary
147///
148
149#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
150pub struct EntitySummary {
151    pub path: String,
152    pub load_calls: u64,
153    pub delete_calls: u64,
154    pub rows_loaded: u64,
155    pub rows_scanned: u64,
156    pub rows_deleted: u64,
157    pub avg_rows_per_load: f64,
158    pub avg_rows_scanned_per_load: f64,
159    pub avg_rows_per_delete: f64,
160    pub index_inserts: u64,
161    pub index_removes: u64,
162    pub unique_violations: u64,
163}
164
165/// Build a metrics report by inspecting in-memory counters only.
166#[must_use]
167#[allow(clippy::cast_precision_loss)]
168pub fn report() -> EventReport {
169    report_since(None)
170}
171
172/// Build a metrics report gated by `since_ms`.
173///
174/// This is a window-start filter:
175/// - If `since_ms` is `None`, return the current window.
176/// - If `since_ms <= state.since_ms`, return the current window.
177/// - If `since_ms > state.since_ms`, return an empty report.
178///
179/// IcyDB stores aggregate counters only, so it cannot produce a precise
180/// sub-window report after `state.since_ms`.
181#[must_use]
182#[allow(clippy::cast_precision_loss)]
183pub fn report_since(since_ms: Option<u64>) -> EventReport {
184    let snap = with_state(Clone::clone);
185    if let Some(requested_since_ms) = since_ms
186        && requested_since_ms > snap.since_ms
187    {
188        return EventReport::default();
189    }
190
191    let mut entity_counters: Vec<EntitySummary> = Vec::new();
192    for (path, ops) in &snap.entities {
193        let avg_load = if ops.load_calls > 0 {
194            ops.rows_loaded as f64 / ops.load_calls as f64
195        } else {
196            0.0
197        };
198        let avg_scanned = if ops.load_calls > 0 {
199            ops.rows_scanned as f64 / ops.load_calls as f64
200        } else {
201            0.0
202        };
203        let avg_delete = if ops.delete_calls > 0 {
204            ops.rows_deleted as f64 / ops.delete_calls as f64
205        } else {
206            0.0
207        };
208
209        entity_counters.push(EntitySummary {
210            path: path.clone(),
211            load_calls: ops.load_calls,
212            delete_calls: ops.delete_calls,
213            rows_loaded: ops.rows_loaded,
214            rows_scanned: ops.rows_scanned,
215            rows_deleted: ops.rows_deleted,
216            avg_rows_per_load: avg_load,
217            avg_rows_scanned_per_load: avg_scanned,
218            avg_rows_per_delete: avg_delete,
219            index_inserts: ops.index_inserts,
220            index_removes: ops.index_removes,
221            unique_violations: ops.unique_violations,
222        });
223    }
224
225    entity_counters.sort_by(|a, b| {
226        match b
227            .avg_rows_per_load
228            .partial_cmp(&a.avg_rows_per_load)
229            .unwrap_or(Ordering::Equal)
230        {
231            Ordering::Equal => match b.rows_loaded.cmp(&a.rows_loaded) {
232                Ordering::Equal => a.path.cmp(&b.path),
233                other => other,
234            },
235            other => other,
236        }
237    });
238
239    EventReport {
240        counters: Some(snap),
241        entity_counters,
242    }
243}
244
245///
246/// TESTS
247///
248
249#[cfg(test)]
250#[allow(clippy::float_cmp)]
251mod tests {
252    use crate::obs::metrics::{EntityCounters, report, reset_all, with_state, with_state_mut};
253
254    #[test]
255    fn reset_all_clears_state() {
256        with_state_mut(|m| {
257            m.ops.load_calls = 3;
258            m.ops.index_inserts = 2;
259            m.perf.save_inst_max = 9;
260            m.entities.insert(
261                "alpha".to_string(),
262                EntityCounters {
263                    load_calls: 1,
264                    ..Default::default()
265                },
266            );
267        });
268
269        reset_all();
270
271        with_state(|m| {
272            assert_eq!(m.ops.load_calls, 0);
273            assert_eq!(m.ops.index_inserts, 0);
274            assert_eq!(m.perf.save_inst_max, 0);
275            assert!(m.entities.is_empty());
276        });
277    }
278
279    #[test]
280    fn report_sorts_entities_by_average_rows() {
281        reset_all();
282        with_state_mut(|m| {
283            m.entities.insert(
284                "alpha".to_string(),
285                EntityCounters {
286                    load_calls: 2,
287                    rows_loaded: 6,
288                    ..Default::default()
289                },
290            );
291            m.entities.insert(
292                "beta".to_string(),
293                EntityCounters {
294                    load_calls: 1,
295                    rows_loaded: 5,
296                    ..Default::default()
297                },
298            );
299            m.entities.insert(
300                "gamma".to_string(),
301                EntityCounters {
302                    load_calls: 2,
303                    rows_loaded: 6,
304                    ..Default::default()
305                },
306            );
307        });
308
309        let report = report();
310        let paths: Vec<_> = report
311            .entity_counters
312            .iter()
313            .map(|e| e.path.as_str())
314            .collect();
315
316        // Order by avg rows per load desc, then rows_loaded desc, then path asc.
317        assert_eq!(paths, ["beta", "alpha", "gamma"]);
318        assert_eq!(report.entity_counters[0].avg_rows_per_load, 5.0);
319        assert_eq!(report.entity_counters[1].avg_rows_per_load, 3.0);
320        assert_eq!(report.entity_counters[2].avg_rows_per_load, 3.0);
321    }
322}