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