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)]
39pub struct EventOps {
40 pub load_calls: u64,
42 pub save_calls: u64,
43 pub delete_calls: u64,
44
45 pub plan_index: u64,
47 pub plan_keys: u64,
48 pub plan_range: u64,
49 pub plan_full_scan: u64,
50
51 pub rows_loaded: u64,
53 pub rows_scanned: u64,
54 pub rows_deleted: u64,
55
56 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#[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#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
93pub struct EventPerf {
94 pub load_inst_total: u128,
96 pub save_inst_total: u128,
97 pub delete_inst_total: u128,
98
99 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
109pub(crate) fn with_state<R>(f: impl FnOnce(&EventState) -> R) -> R {
111 EVENT_STATE.with(|m| f(&m.borrow()))
112}
113
114pub(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
119pub(super) fn reset() {
121 with_state_mut(|m| *m = EventState::default());
122}
123
124pub(crate) fn reset_all() {
126 reset();
127}
128
129pub(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#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
142pub struct EventReport {
143 pub counters: Option<EventState>,
145 pub entity_counters: Vec<EntitySummary>,
147}
148
149#[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#[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#[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 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}