1use candid::CandidType;
6use canic_cdk::utils::time::now_millis;
7use serde::{Deserialize, Serialize};
8use std::{cell::RefCell, cmp::Ordering, collections::BTreeMap};
9
10#[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#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
40pub struct EventOps {
41 pub load_calls: u64,
43 pub save_calls: u64,
44 pub delete_calls: u64,
45
46 pub plan_index: u64,
48 pub plan_keys: u64,
49 pub plan_range: u64,
50 pub plan_full_scan: u64,
51
52 pub rows_loaded: u64,
54 pub rows_scanned: u64,
55 pub rows_deleted: u64,
56
57 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#[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#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
95pub struct EventPerf {
96 pub load_inst_total: u128,
98 pub save_inst_total: u128,
99 pub delete_inst_total: u128,
100
101 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
111pub(crate) fn with_state<R>(f: impl FnOnce(&EventState) -> R) -> R {
113 EVENT_STATE.with(|m| f(&m.borrow()))
114}
115
116pub(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
121pub fn reset() {
123 with_state_mut(|m| *m = EventState::default());
124}
125
126pub fn reset_all() {
128 reset();
129}
130
131pub 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#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
145pub struct EventReport {
146 pub counters: Option<EventState>,
148 pub entity_counters: Vec<EntitySummary>,
150}
151
152#[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#[must_use]
178pub fn report() -> EventReport {
179 report_window_start(None)
180}
181
182#[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#[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 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}