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