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