Skip to main content

icydb_core/obs/metrics/
mod.rs

1//! Module: obs::metrics
2//! Responsibility: module-local ownership and contracts for obs::metrics.
3//! Does not own: cross-module orchestration outside this module.
4//! Boundary: exposes this module API while keeping implementation details internal.
5
6//! Runtime metrics are update-only by contract.
7//! Query-side instrumentation is intentionally not surfaced by `report`, so
8//! query metrics are non-existent by design under IC query semantics.
9
10#[cfg(test)]
11#[expect(clippy::float_cmp)]
12mod tests;
13
14use candid::CandidType;
15use canic_cdk::utils::time::now_millis;
16use serde::{Deserialize, Serialize};
17use std::{cell::RefCell, cmp::Ordering, collections::BTreeMap};
18
19/// EventState
20/// Mutable runtime counters and rolling perf state for the current window.
21/// Stored in thread-local memory for update-only instrumentation.
22
23#[derive(CandidType, Clone, Debug, Deserialize, Serialize)]
24pub struct EventState {
25    pub(crate) ops: EventOps,
26    pub(crate) perf: EventPerf,
27    pub(crate) entities: BTreeMap<String, EntityCounters>,
28    pub(crate) window_start_ms: u64,
29}
30
31impl EventState {
32    #[must_use]
33    pub const fn new(
34        ops: EventOps,
35        perf: EventPerf,
36        entities: BTreeMap<String, EntityCounters>,
37        window_start_ms: u64,
38    ) -> Self {
39        Self {
40            ops,
41            perf,
42            entities,
43            window_start_ms,
44        }
45    }
46
47    #[must_use]
48    pub const fn ops(&self) -> &EventOps {
49        &self.ops
50    }
51
52    #[must_use]
53    pub const fn perf(&self) -> &EventPerf {
54        &self.perf
55    }
56
57    #[must_use]
58    pub const fn entities(&self) -> &BTreeMap<String, EntityCounters> {
59        &self.entities
60    }
61
62    #[must_use]
63    pub const fn window_start_ms(&self) -> u64 {
64        self.window_start_ms
65    }
66}
67
68impl Default for EventState {
69    fn default() -> Self {
70        Self {
71            ops: EventOps::default(),
72            perf: EventPerf::default(),
73            entities: BTreeMap::new(),
74            window_start_ms: now_millis(),
75        }
76    }
77}
78
79/// EventOps
80/// Aggregated operation counters for executors, plans, rows, and index maintenance.
81/// Values are monotonic within a metrics window.
82/// Call counters are execution attempts; errors still increment them.
83/// Row counters reflect rows touched after execution, not requested rows.
84#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
85pub struct EventOps {
86    // Executor entrypoints
87    pub(crate) load_calls: u64,
88    pub(crate) save_calls: u64,
89    pub(crate) delete_calls: u64,
90
91    // Planner kinds
92    pub(crate) plan_index: u64,
93    pub(crate) plan_keys: u64,
94    pub(crate) plan_range: u64,
95    pub(crate) plan_full_scan: u64,
96    pub(crate) plan_grouped_hash_materialized: u64,
97    pub(crate) plan_grouped_ordered_materialized: u64,
98
99    // Rows touched
100    pub(crate) rows_loaded: u64,
101    pub(crate) rows_scanned: u64,
102    pub(crate) rows_deleted: u64,
103
104    // Index maintenance
105    pub(crate) index_inserts: u64,
106    pub(crate) index_removes: u64,
107    pub(crate) reverse_index_inserts: u64,
108    pub(crate) reverse_index_removes: u64,
109    pub(crate) relation_reverse_lookups: u64,
110    pub(crate) relation_delete_blocks: u64,
111    pub(crate) unique_violations: u64,
112    pub(crate) non_atomic_partial_commits: u64,
113    pub(crate) non_atomic_partial_rows_committed: u64,
114}
115
116impl EventOps {
117    #[must_use]
118    pub const fn load_calls(&self) -> u64 {
119        self.load_calls
120    }
121
122    #[must_use]
123    pub const fn save_calls(&self) -> u64 {
124        self.save_calls
125    }
126
127    #[must_use]
128    pub const fn delete_calls(&self) -> u64 {
129        self.delete_calls
130    }
131
132    #[must_use]
133    pub const fn plan_index(&self) -> u64 {
134        self.plan_index
135    }
136
137    #[must_use]
138    pub const fn plan_keys(&self) -> u64 {
139        self.plan_keys
140    }
141
142    #[must_use]
143    pub const fn plan_range(&self) -> u64 {
144        self.plan_range
145    }
146
147    #[must_use]
148    pub const fn plan_full_scan(&self) -> u64 {
149        self.plan_full_scan
150    }
151
152    #[must_use]
153    pub const fn plan_grouped_hash_materialized(&self) -> u64 {
154        self.plan_grouped_hash_materialized
155    }
156
157    #[must_use]
158    pub const fn plan_grouped_ordered_materialized(&self) -> u64 {
159        self.plan_grouped_ordered_materialized
160    }
161
162    #[must_use]
163    pub const fn rows_loaded(&self) -> u64 {
164        self.rows_loaded
165    }
166
167    #[must_use]
168    pub const fn rows_scanned(&self) -> u64 {
169        self.rows_scanned
170    }
171
172    #[must_use]
173    pub const fn rows_deleted(&self) -> u64 {
174        self.rows_deleted
175    }
176
177    #[must_use]
178    pub const fn index_inserts(&self) -> u64 {
179        self.index_inserts
180    }
181
182    #[must_use]
183    pub const fn index_removes(&self) -> u64 {
184        self.index_removes
185    }
186
187    #[must_use]
188    pub const fn reverse_index_inserts(&self) -> u64 {
189        self.reverse_index_inserts
190    }
191
192    #[must_use]
193    pub const fn reverse_index_removes(&self) -> u64 {
194        self.reverse_index_removes
195    }
196
197    #[must_use]
198    pub const fn relation_reverse_lookups(&self) -> u64 {
199        self.relation_reverse_lookups
200    }
201
202    #[must_use]
203    pub const fn relation_delete_blocks(&self) -> u64 {
204        self.relation_delete_blocks
205    }
206
207    #[must_use]
208    pub const fn unique_violations(&self) -> u64 {
209        self.unique_violations
210    }
211
212    #[must_use]
213    pub const fn non_atomic_partial_commits(&self) -> u64 {
214        self.non_atomic_partial_commits
215    }
216
217    #[must_use]
218    pub const fn non_atomic_partial_rows_committed(&self) -> u64 {
219        self.non_atomic_partial_rows_committed
220    }
221}
222
223/// EntityCounters
224/// Per-entity counters mirroring `EventOps` categories.
225/// Used to compute report-level per-entity summaries.
226
227#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
228pub struct EntityCounters {
229    pub(crate) load_calls: u64,
230    pub(crate) save_calls: u64,
231    pub(crate) delete_calls: u64,
232    pub(crate) rows_loaded: u64,
233    pub(crate) rows_scanned: u64,
234    pub(crate) rows_deleted: u64,
235    pub(crate) index_inserts: u64,
236    pub(crate) index_removes: u64,
237    pub(crate) reverse_index_inserts: u64,
238    pub(crate) reverse_index_removes: u64,
239    pub(crate) relation_reverse_lookups: u64,
240    pub(crate) relation_delete_blocks: u64,
241    pub(crate) unique_violations: u64,
242    pub(crate) non_atomic_partial_commits: u64,
243    pub(crate) non_atomic_partial_rows_committed: u64,
244}
245
246impl EntityCounters {
247    #[must_use]
248    pub const fn load_calls(&self) -> u64 {
249        self.load_calls
250    }
251
252    #[must_use]
253    pub const fn save_calls(&self) -> u64 {
254        self.save_calls
255    }
256
257    #[must_use]
258    pub const fn delete_calls(&self) -> u64 {
259        self.delete_calls
260    }
261
262    #[must_use]
263    pub const fn rows_loaded(&self) -> u64 {
264        self.rows_loaded
265    }
266
267    #[must_use]
268    pub const fn rows_scanned(&self) -> u64 {
269        self.rows_scanned
270    }
271
272    #[must_use]
273    pub const fn rows_deleted(&self) -> u64 {
274        self.rows_deleted
275    }
276
277    #[must_use]
278    pub const fn index_inserts(&self) -> u64 {
279        self.index_inserts
280    }
281
282    #[must_use]
283    pub const fn index_removes(&self) -> u64 {
284        self.index_removes
285    }
286
287    #[must_use]
288    pub const fn reverse_index_inserts(&self) -> u64 {
289        self.reverse_index_inserts
290    }
291
292    #[must_use]
293    pub const fn reverse_index_removes(&self) -> u64 {
294        self.reverse_index_removes
295    }
296
297    #[must_use]
298    pub const fn relation_reverse_lookups(&self) -> u64 {
299        self.relation_reverse_lookups
300    }
301
302    #[must_use]
303    pub const fn relation_delete_blocks(&self) -> u64 {
304        self.relation_delete_blocks
305    }
306
307    #[must_use]
308    pub const fn unique_violations(&self) -> u64 {
309        self.unique_violations
310    }
311
312    #[must_use]
313    pub const fn non_atomic_partial_commits(&self) -> u64 {
314        self.non_atomic_partial_commits
315    }
316
317    #[must_use]
318    pub const fn non_atomic_partial_rows_committed(&self) -> u64 {
319        self.non_atomic_partial_rows_committed
320    }
321}
322
323/// EventPerf
324/// Aggregate and max instruction deltas per executor kind.
325/// Captures execution pressure, not wall-clock latency.
326/// Instruction deltas are pressure indicators (validation + planning + execution),
327/// not latency measurements.
328#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
329pub struct EventPerf {
330    // Instruction totals per executor (ic_cdk::api::performance_counter(1))
331    pub(crate) load_inst_total: u128,
332    pub(crate) save_inst_total: u128,
333    pub(crate) delete_inst_total: u128,
334
335    // Maximum observed instruction deltas
336    pub(crate) load_inst_max: u64,
337    pub(crate) save_inst_max: u64,
338    pub(crate) delete_inst_max: u64,
339}
340
341impl EventPerf {
342    #[must_use]
343    pub const fn new(
344        load_inst_total: u128,
345        save_inst_total: u128,
346        delete_inst_total: u128,
347        load_inst_max: u64,
348        save_inst_max: u64,
349        delete_inst_max: u64,
350    ) -> Self {
351        Self {
352            load_inst_total,
353            save_inst_total,
354            delete_inst_total,
355            load_inst_max,
356            save_inst_max,
357            delete_inst_max,
358        }
359    }
360
361    #[must_use]
362    pub const fn load_inst_total(&self) -> u128 {
363        self.load_inst_total
364    }
365
366    #[must_use]
367    pub const fn save_inst_total(&self) -> u128 {
368        self.save_inst_total
369    }
370
371    #[must_use]
372    pub const fn delete_inst_total(&self) -> u128 {
373        self.delete_inst_total
374    }
375
376    #[must_use]
377    pub const fn load_inst_max(&self) -> u64 {
378        self.load_inst_max
379    }
380
381    #[must_use]
382    pub const fn save_inst_max(&self) -> u64 {
383        self.save_inst_max
384    }
385
386    #[must_use]
387    pub const fn delete_inst_max(&self) -> u64 {
388        self.delete_inst_max
389    }
390}
391
392thread_local! {
393    static EVENT_STATE: RefCell<EventState> = RefCell::new(EventState::default());
394}
395
396/// Borrow metrics immutably.
397pub(crate) fn with_state<R>(f: impl FnOnce(&EventState) -> R) -> R {
398    EVENT_STATE.with(|m| f(&m.borrow()))
399}
400
401/// Borrow metrics mutably.
402pub(crate) fn with_state_mut<R>(f: impl FnOnce(&mut EventState) -> R) -> R {
403    EVENT_STATE.with(|m| f(&mut m.borrow_mut()))
404}
405
406/// Reset all counters (useful in tests).
407pub(super) fn reset() {
408    with_state_mut(|m| *m = EventState::default());
409}
410
411/// Reset all event state: counters, perf, and serialize counters.
412pub(crate) fn reset_all() {
413    reset();
414}
415
416/// Accumulate instruction counts and track a max.
417pub(super) fn add_instructions(total: &mut u128, max: &mut u64, delta_inst: u64) {
418    *total = total.saturating_add(u128::from(delta_inst));
419    if delta_inst > *max {
420        *max = delta_inst;
421    }
422}
423
424/// EventReport
425/// Event/counter report for runtime metrics query endpoints.
426/// Storage snapshot types live in snapshot/storage modules.
427
428#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
429pub struct EventReport {
430    /// Ephemeral runtime counters since `window_start_ms`.
431    counters: Option<EventState>,
432    /// Per-entity ephemeral counters and averages.
433    entity_counters: Vec<EntitySummary>,
434}
435
436impl EventReport {
437    #[must_use]
438    pub(crate) const fn new(
439        counters: Option<EventState>,
440        entity_counters: Vec<EntitySummary>,
441    ) -> Self {
442        Self {
443            counters,
444            entity_counters,
445        }
446    }
447
448    #[must_use]
449    pub const fn counters(&self) -> Option<&EventState> {
450        self.counters.as_ref()
451    }
452
453    #[must_use]
454    pub fn entity_counters(&self) -> &[EntitySummary] {
455        &self.entity_counters
456    }
457
458    #[must_use]
459    pub fn into_counters(self) -> Option<EventState> {
460        self.counters
461    }
462
463    #[must_use]
464    pub fn into_entity_counters(self) -> Vec<EntitySummary> {
465        self.entity_counters
466    }
467}
468
469/// EntitySummary
470/// Derived per-entity metrics for report consumers.
471/// Includes absolute counters and simple averages.
472
473#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
474pub struct EntitySummary {
475    path: String,
476    load_calls: u64,
477    delete_calls: u64,
478    rows_loaded: u64,
479    rows_scanned: u64,
480    rows_deleted: u64,
481    avg_rows_per_load: f64,
482    avg_rows_scanned_per_load: f64,
483    avg_rows_per_delete: f64,
484    index_inserts: u64,
485    index_removes: u64,
486    reverse_index_inserts: u64,
487    reverse_index_removes: u64,
488    relation_reverse_lookups: u64,
489    relation_delete_blocks: u64,
490    unique_violations: u64,
491    non_atomic_partial_commits: u64,
492    non_atomic_partial_rows_committed: u64,
493}
494
495impl EntitySummary {
496    #[must_use]
497    pub const fn path(&self) -> &str {
498        self.path.as_str()
499    }
500
501    #[must_use]
502    pub const fn load_calls(&self) -> u64 {
503        self.load_calls
504    }
505
506    #[must_use]
507    pub const fn delete_calls(&self) -> u64 {
508        self.delete_calls
509    }
510
511    #[must_use]
512    pub const fn rows_loaded(&self) -> u64 {
513        self.rows_loaded
514    }
515
516    #[must_use]
517    pub const fn rows_scanned(&self) -> u64 {
518        self.rows_scanned
519    }
520
521    #[must_use]
522    pub const fn rows_deleted(&self) -> u64 {
523        self.rows_deleted
524    }
525
526    #[must_use]
527    pub const fn avg_rows_per_load(&self) -> f64 {
528        self.avg_rows_per_load
529    }
530
531    #[must_use]
532    pub const fn avg_rows_scanned_per_load(&self) -> f64 {
533        self.avg_rows_scanned_per_load
534    }
535
536    #[must_use]
537    pub const fn avg_rows_per_delete(&self) -> f64 {
538        self.avg_rows_per_delete
539    }
540
541    #[must_use]
542    pub const fn index_inserts(&self) -> u64 {
543        self.index_inserts
544    }
545
546    #[must_use]
547    pub const fn index_removes(&self) -> u64 {
548        self.index_removes
549    }
550
551    #[must_use]
552    pub const fn reverse_index_inserts(&self) -> u64 {
553        self.reverse_index_inserts
554    }
555
556    #[must_use]
557    pub const fn reverse_index_removes(&self) -> u64 {
558        self.reverse_index_removes
559    }
560
561    #[must_use]
562    pub const fn relation_reverse_lookups(&self) -> u64 {
563        self.relation_reverse_lookups
564    }
565
566    #[must_use]
567    pub const fn relation_delete_blocks(&self) -> u64 {
568        self.relation_delete_blocks
569    }
570
571    #[must_use]
572    pub const fn unique_violations(&self) -> u64 {
573        self.unique_violations
574    }
575
576    #[must_use]
577    pub const fn non_atomic_partial_commits(&self) -> u64 {
578        self.non_atomic_partial_commits
579    }
580
581    #[must_use]
582    pub const fn non_atomic_partial_rows_committed(&self) -> u64 {
583        self.non_atomic_partial_rows_committed
584    }
585}
586
587/// Build a metrics report gated by `window_start_ms`.
588///
589/// This is a window-start filter:
590/// - If `window_start_ms` is `None`, return the current window.
591/// - If `window_start_ms <= state.window_start_ms`, return the current window.
592/// - If `window_start_ms > state.window_start_ms`, return an empty report.
593///
594/// IcyDB stores aggregate counters only, so it cannot produce a precise
595/// sub-window report after `state.window_start_ms`.
596#[must_use]
597#[expect(clippy::cast_precision_loss)]
598pub(super) fn report_window_start(window_start_ms: Option<u64>) -> EventReport {
599    let snap = with_state(Clone::clone);
600    if let Some(requested_window_start_ms) = window_start_ms
601        && requested_window_start_ms > snap.window_start_ms
602    {
603        return EventReport::default();
604    }
605
606    let mut entity_counters: Vec<EntitySummary> = Vec::new();
607    for (path, ops) in &snap.entities {
608        let avg_load = if ops.load_calls > 0 {
609            ops.rows_loaded as f64 / ops.load_calls as f64
610        } else {
611            0.0
612        };
613        let avg_scanned = if ops.load_calls > 0 {
614            ops.rows_scanned as f64 / ops.load_calls as f64
615        } else {
616            0.0
617        };
618        let avg_delete = if ops.delete_calls > 0 {
619            ops.rows_deleted as f64 / ops.delete_calls as f64
620        } else {
621            0.0
622        };
623
624        entity_counters.push(EntitySummary {
625            path: path.clone(),
626            load_calls: ops.load_calls,
627            delete_calls: ops.delete_calls,
628            rows_loaded: ops.rows_loaded,
629            rows_scanned: ops.rows_scanned,
630            rows_deleted: ops.rows_deleted,
631            avg_rows_per_load: avg_load,
632            avg_rows_scanned_per_load: avg_scanned,
633            avg_rows_per_delete: avg_delete,
634            index_inserts: ops.index_inserts,
635            index_removes: ops.index_removes,
636            reverse_index_inserts: ops.reverse_index_inserts,
637            reverse_index_removes: ops.reverse_index_removes,
638            relation_reverse_lookups: ops.relation_reverse_lookups,
639            relation_delete_blocks: ops.relation_delete_blocks,
640            unique_violations: ops.unique_violations,
641            non_atomic_partial_commits: ops.non_atomic_partial_commits,
642            non_atomic_partial_rows_committed: ops.non_atomic_partial_rows_committed,
643        });
644    }
645
646    entity_counters.sort_by(|a, b| {
647        match b
648            .avg_rows_per_load
649            .partial_cmp(&a.avg_rows_per_load)
650            .unwrap_or(Ordering::Equal)
651        {
652            Ordering::Equal => match b.rows_loaded.cmp(&a.rows_loaded) {
653                Ordering::Equal => a.path.cmp(&b.path),
654                other => other,
655            },
656            other => other,
657        }
658    });
659
660    EventReport::new(Some(snap), entity_counters)
661}