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 since_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 since_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 unique_violations: u64,
61}
62
63#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
68pub struct EntityCounters {
69 pub load_calls: u64,
70 pub save_calls: u64,
71 pub delete_calls: u64,
72 pub rows_loaded: u64,
73 pub rows_scanned: u64,
74 pub rows_deleted: u64,
75 pub index_inserts: u64,
76 pub index_removes: u64,
77 pub unique_violations: u64,
78}
79
80#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
87pub struct EventPerf {
88 pub load_inst_total: u128,
90 pub save_inst_total: u128,
91 pub delete_inst_total: u128,
92
93 pub load_inst_max: u64,
95 pub save_inst_max: u64,
96 pub delete_inst_max: u64,
97}
98
99thread_local! {
100 static EVENT_STATE: RefCell<EventState> = RefCell::new(EventState::default());
101}
102
103pub(crate) fn with_state<R>(f: impl FnOnce(&EventState) -> R) -> R {
105 EVENT_STATE.with(|m| f(&m.borrow()))
106}
107
108pub(crate) fn with_state_mut<R>(f: impl FnOnce(&mut EventState) -> R) -> R {
110 EVENT_STATE.with(|m| f(&mut m.borrow_mut()))
111}
112
113pub fn reset() {
115 with_state_mut(|m| *m = EventState::default());
116}
117
118pub fn reset_all() {
120 reset();
121}
122
123#[allow(clippy::missing_const_for_fn)]
125pub fn add_instructions(total: &mut u128, max: &mut u64, delta_inst: u64) {
126 *total = total.saturating_add(u128::from(delta_inst));
127 if delta_inst > *max {
128 *max = delta_inst;
129 }
130}
131
132#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
138pub struct EventReport {
139 pub counters: Option<EventState>,
141 pub entity_counters: Vec<EntitySummary>,
143}
144
145#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
150pub struct EntitySummary {
151 pub path: String,
152 pub load_calls: u64,
153 pub delete_calls: u64,
154 pub rows_loaded: u64,
155 pub rows_scanned: u64,
156 pub rows_deleted: u64,
157 pub avg_rows_per_load: f64,
158 pub avg_rows_scanned_per_load: f64,
159 pub avg_rows_per_delete: f64,
160 pub index_inserts: u64,
161 pub index_removes: u64,
162 pub unique_violations: u64,
163}
164
165#[derive(CandidType, Clone, Copy, Debug, Deserialize, Serialize)]
171#[allow(clippy::struct_excessive_bools)]
172pub struct EventSelect {
173 pub data: bool,
174 pub index: bool,
175 pub counters: bool,
176 pub entities: bool,
177}
178
179impl EventSelect {
180 #[must_use]
181 pub const fn all() -> Self {
182 Self {
183 data: true,
184 index: true,
185 counters: true,
186 entities: true,
187 }
188 }
189}
190
191impl Default for EventSelect {
192 fn default() -> Self {
193 Self::all()
194 }
195}
196
197#[must_use]
199#[allow(clippy::cast_precision_loss)]
200pub fn report() -> EventReport {
201 let snap = with_state(Clone::clone);
202
203 let mut entity_counters: Vec<EntitySummary> = Vec::new();
204 for (path, ops) in &snap.entities {
205 let avg_load = if ops.load_calls > 0 {
206 ops.rows_loaded as f64 / ops.load_calls as f64
207 } else {
208 0.0
209 };
210 let avg_scanned = if ops.load_calls > 0 {
211 ops.rows_scanned as f64 / ops.load_calls as f64
212 } else {
213 0.0
214 };
215 let avg_delete = if ops.delete_calls > 0 {
216 ops.rows_deleted as f64 / ops.delete_calls as f64
217 } else {
218 0.0
219 };
220
221 entity_counters.push(EntitySummary {
222 path: path.clone(),
223 load_calls: ops.load_calls,
224 delete_calls: ops.delete_calls,
225 rows_loaded: ops.rows_loaded,
226 rows_scanned: ops.rows_scanned,
227 rows_deleted: ops.rows_deleted,
228 avg_rows_per_load: avg_load,
229 avg_rows_scanned_per_load: avg_scanned,
230 avg_rows_per_delete: avg_delete,
231 index_inserts: ops.index_inserts,
232 index_removes: ops.index_removes,
233 unique_violations: ops.unique_violations,
234 });
235 }
236
237 entity_counters.sort_by(|a, b| {
238 match b
239 .avg_rows_per_load
240 .partial_cmp(&a.avg_rows_per_load)
241 .unwrap_or(Ordering::Equal)
242 {
243 Ordering::Equal => match b.rows_loaded.cmp(&a.rows_loaded) {
244 Ordering::Equal => a.path.cmp(&b.path),
245 other => other,
246 },
247 other => other,
248 }
249 });
250
251 EventReport {
252 counters: Some(snap),
253 entity_counters,
254 }
255}
256
257#[cfg(test)]
262#[allow(clippy::float_cmp)]
263mod tests {
264 use crate::obs::metrics::{EntityCounters, report, reset_all, with_state, with_state_mut};
265
266 #[test]
267 fn reset_all_clears_state() {
268 with_state_mut(|m| {
269 m.ops.load_calls = 3;
270 m.ops.index_inserts = 2;
271 m.perf.save_inst_max = 9;
272 m.entities.insert(
273 "alpha".to_string(),
274 EntityCounters {
275 load_calls: 1,
276 ..Default::default()
277 },
278 );
279 });
280
281 reset_all();
282
283 with_state(|m| {
284 assert_eq!(m.ops.load_calls, 0);
285 assert_eq!(m.ops.index_inserts, 0);
286 assert_eq!(m.perf.save_inst_max, 0);
287 assert!(m.entities.is_empty());
288 });
289 }
290
291 #[test]
292 fn report_sorts_entities_by_average_rows() {
293 reset_all();
294 with_state_mut(|m| {
295 m.entities.insert(
296 "alpha".to_string(),
297 EntityCounters {
298 load_calls: 2,
299 rows_loaded: 6,
300 ..Default::default()
301 },
302 );
303 m.entities.insert(
304 "beta".to_string(),
305 EntityCounters {
306 load_calls: 1,
307 rows_loaded: 5,
308 ..Default::default()
309 },
310 );
311 m.entities.insert(
312 "gamma".to_string(),
313 EntityCounters {
314 load_calls: 2,
315 rows_loaded: 6,
316 ..Default::default()
317 },
318 );
319 });
320
321 let report = report();
322 let paths: Vec<_> = report
323 .entity_counters
324 .iter()
325 .map(|e| e.path.as_str())
326 .collect();
327
328 assert_eq!(paths, ["beta", "alpha", "gamma"]);
330 assert_eq!(report.entity_counters[0].avg_rows_per_load, 5.0);
331 assert_eq!(report.entity_counters[1].avg_rows_per_load, 3.0);
332 assert_eq!(report.entity_counters[2].avg_rows_per_load, 3.0);
333 }
334}