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 pub plan_grouped_hash_materialized: u64,
51 pub plan_grouped_ordered_materialized: u64,
52
53 pub rows_loaded: u64,
55 pub rows_scanned: u64,
56 pub rows_deleted: u64,
57
58 pub index_inserts: u64,
60 pub index_removes: u64,
61 pub reverse_index_inserts: u64,
62 pub reverse_index_removes: u64,
63 pub relation_reverse_lookups: u64,
64 pub relation_delete_blocks: u64,
65 pub unique_violations: u64,
66 pub non_atomic_partial_commits: u64,
67 pub non_atomic_partial_rows_committed: u64,
68}
69
70#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
75pub struct EntityCounters {
76 pub load_calls: u64,
77 pub save_calls: u64,
78 pub delete_calls: u64,
79 pub rows_loaded: u64,
80 pub rows_scanned: u64,
81 pub rows_deleted: u64,
82 pub index_inserts: u64,
83 pub index_removes: u64,
84 pub reverse_index_inserts: u64,
85 pub reverse_index_removes: u64,
86 pub relation_reverse_lookups: u64,
87 pub relation_delete_blocks: u64,
88 pub unique_violations: u64,
89 pub non_atomic_partial_commits: u64,
90 pub non_atomic_partial_rows_committed: u64,
91}
92
93#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
99pub struct EventPerf {
100 pub load_inst_total: u128,
102 pub save_inst_total: u128,
103 pub delete_inst_total: u128,
104
105 pub load_inst_max: u64,
107 pub save_inst_max: u64,
108 pub delete_inst_max: u64,
109}
110
111thread_local! {
112 static EVENT_STATE: RefCell<EventState> = RefCell::new(EventState::default());
113}
114
115pub(crate) fn with_state<R>(f: impl FnOnce(&EventState) -> R) -> R {
117 EVENT_STATE.with(|m| f(&m.borrow()))
118}
119
120pub(crate) fn with_state_mut<R>(f: impl FnOnce(&mut EventState) -> R) -> R {
122 EVENT_STATE.with(|m| f(&mut m.borrow_mut()))
123}
124
125pub(super) fn reset() {
127 with_state_mut(|m| *m = EventState::default());
128}
129
130pub(crate) fn reset_all() {
132 reset();
133}
134
135pub(super) fn add_instructions(total: &mut u128, max: &mut u64, delta_inst: u64) {
137 *total = total.saturating_add(u128::from(delta_inst));
138 if delta_inst > *max {
139 *max = delta_inst;
140 }
141}
142
143#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
148pub struct EventReport {
149 pub counters: Option<EventState>,
151 pub entity_counters: Vec<EntitySummary>,
153}
154
155#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
160pub struct EntitySummary {
161 pub path: String,
162 pub load_calls: u64,
163 pub delete_calls: u64,
164 pub rows_loaded: u64,
165 pub rows_scanned: u64,
166 pub rows_deleted: u64,
167 pub avg_rows_per_load: f64,
168 pub avg_rows_scanned_per_load: f64,
169 pub avg_rows_per_delete: f64,
170 pub index_inserts: u64,
171 pub index_removes: u64,
172 pub reverse_index_inserts: u64,
173 pub reverse_index_removes: u64,
174 pub relation_reverse_lookups: u64,
175 pub relation_delete_blocks: u64,
176 pub unique_violations: u64,
177 pub non_atomic_partial_commits: u64,
178 pub non_atomic_partial_rows_committed: u64,
179}
180
181#[must_use]
191#[expect(clippy::cast_precision_loss)]
192pub(super) fn report_window_start(window_start_ms: Option<u64>) -> EventReport {
193 let snap = with_state(Clone::clone);
194 if let Some(requested_window_start_ms) = window_start_ms
195 && requested_window_start_ms > snap.window_start_ms
196 {
197 return EventReport::default();
198 }
199
200 let mut entity_counters: Vec<EntitySummary> = Vec::new();
201 for (path, ops) in &snap.entities {
202 let avg_load = if ops.load_calls > 0 {
203 ops.rows_loaded as f64 / ops.load_calls as f64
204 } else {
205 0.0
206 };
207 let avg_scanned = if ops.load_calls > 0 {
208 ops.rows_scanned as f64 / ops.load_calls as f64
209 } else {
210 0.0
211 };
212 let avg_delete = if ops.delete_calls > 0 {
213 ops.rows_deleted as f64 / ops.delete_calls as f64
214 } else {
215 0.0
216 };
217
218 entity_counters.push(EntitySummary {
219 path: path.clone(),
220 load_calls: ops.load_calls,
221 delete_calls: ops.delete_calls,
222 rows_loaded: ops.rows_loaded,
223 rows_scanned: ops.rows_scanned,
224 rows_deleted: ops.rows_deleted,
225 avg_rows_per_load: avg_load,
226 avg_rows_scanned_per_load: avg_scanned,
227 avg_rows_per_delete: avg_delete,
228 index_inserts: ops.index_inserts,
229 index_removes: ops.index_removes,
230 reverse_index_inserts: ops.reverse_index_inserts,
231 reverse_index_removes: ops.reverse_index_removes,
232 relation_reverse_lookups: ops.relation_reverse_lookups,
233 relation_delete_blocks: ops.relation_delete_blocks,
234 unique_violations: ops.unique_violations,
235 non_atomic_partial_commits: ops.non_atomic_partial_commits,
236 non_atomic_partial_rows_committed: ops.non_atomic_partial_rows_committed,
237 });
238 }
239
240 entity_counters.sort_by(|a, b| {
241 match b
242 .avg_rows_per_load
243 .partial_cmp(&a.avg_rows_per_load)
244 .unwrap_or(Ordering::Equal)
245 {
246 Ordering::Equal => match b.rows_loaded.cmp(&a.rows_loaded) {
247 Ordering::Equal => a.path.cmp(&b.path),
248 other => other,
249 },
250 other => other,
251 }
252 });
253
254 EventReport {
255 counters: Some(snap),
256 entity_counters,
257 }
258}
259
260#[cfg(test)]
265#[expect(clippy::float_cmp)]
266mod tests {
267 use crate::obs::metrics::{
268 EntityCounters, EntitySummary, EventOps, EventPerf, EventReport, EventState,
269 report_window_start, reset_all, with_state, with_state_mut,
270 };
271 use serde::Serialize;
272 use serde_cbor::Value as CborValue;
273 use std::collections::BTreeMap;
274
275 fn to_cbor_value<T: Serialize>(value: &T) -> CborValue {
276 let bytes =
277 serde_cbor::to_vec(value).expect("test fixtures must serialize into CBOR payloads");
278 serde_cbor::from_slice::<CborValue>(&bytes)
279 .expect("test fixtures must deserialize into CBOR value trees")
280 }
281
282 fn expect_cbor_map(value: &CborValue) -> &BTreeMap<CborValue, CborValue> {
283 match value {
284 CborValue::Map(map) => map,
285 other => panic!("expected CBOR map, got {other:?}"),
286 }
287 }
288
289 fn map_field<'a>(map: &'a BTreeMap<CborValue, CborValue>, key: &str) -> Option<&'a CborValue> {
290 map.get(&CborValue::Text(key.to_string()))
291 }
292
293 #[test]
294 fn reset_all_clears_state() {
295 with_state_mut(|m| {
296 m.ops.load_calls = 3;
297 m.ops.index_inserts = 2;
298 m.perf.save_inst_max = 9;
299 m.entities.insert(
300 "alpha".to_string(),
301 EntityCounters {
302 load_calls: 1,
303 ..Default::default()
304 },
305 );
306 });
307
308 reset_all();
309
310 with_state(|m| {
311 assert_eq!(m.ops.load_calls, 0);
312 assert_eq!(m.ops.index_inserts, 0);
313 assert_eq!(m.perf.save_inst_max, 0);
314 assert!(m.entities.is_empty());
315 });
316 }
317
318 #[test]
319 fn report_sorts_entities_by_average_rows() {
320 reset_all();
321 with_state_mut(|m| {
322 m.entities.insert(
323 "alpha".to_string(),
324 EntityCounters {
325 load_calls: 2,
326 rows_loaded: 6,
327 ..Default::default()
328 },
329 );
330 m.entities.insert(
331 "beta".to_string(),
332 EntityCounters {
333 load_calls: 1,
334 rows_loaded: 5,
335 ..Default::default()
336 },
337 );
338 m.entities.insert(
339 "gamma".to_string(),
340 EntityCounters {
341 load_calls: 2,
342 rows_loaded: 6,
343 ..Default::default()
344 },
345 );
346 });
347
348 let report = report_window_start(None);
349 let paths: Vec<_> = report
350 .entity_counters
351 .iter()
352 .map(|e| e.path.as_str())
353 .collect();
354
355 assert_eq!(paths, ["beta", "alpha", "gamma"]);
357 assert_eq!(report.entity_counters[0].avg_rows_per_load, 5.0);
358 assert_eq!(report.entity_counters[1].avg_rows_per_load, 3.0);
359 assert_eq!(report.entity_counters[2].avg_rows_per_load, 3.0);
360 }
361
362 #[test]
363 fn event_report_serialization_shape_is_stable() {
364 let report = EventReport {
365 counters: Some(EventState {
366 ops: EventOps {
367 load_calls: 1,
368 rows_loaded: 2,
369 rows_scanned: 3,
370 non_atomic_partial_rows_committed: 4,
371 ..Default::default()
372 },
373 perf: EventPerf {
374 load_inst_total: 11,
375 load_inst_max: 12,
376 ..Default::default()
377 },
378 entities: BTreeMap::from([(
379 "alpha".to_string(),
380 EntityCounters {
381 load_calls: 5,
382 rows_loaded: 8,
383 ..Default::default()
384 },
385 )]),
386 window_start_ms: 99,
387 }),
388 entity_counters: vec![EntitySummary {
389 path: "alpha".to_string(),
390 load_calls: 5,
391 rows_loaded: 8,
392 avg_rows_per_load: 1.6,
393 ..Default::default()
394 }],
395 };
396
397 let encoded = to_cbor_value(&report);
398 let root = expect_cbor_map(&encoded);
399 assert!(
400 map_field(root, "counters").is_some(),
401 "EventReport must keep `counters` as serialized field key",
402 );
403 assert!(
404 map_field(root, "entity_counters").is_some(),
405 "EventReport must keep `entity_counters` as serialized field key",
406 );
407
408 let counters = map_field(root, "counters").expect("counters payload should exist");
409 let counters_map = expect_cbor_map(counters);
410 assert!(
411 map_field(counters_map, "ops").is_some(),
412 "EventState must keep `ops` as serialized field key",
413 );
414 assert!(
415 map_field(counters_map, "perf").is_some(),
416 "EventState must keep `perf` as serialized field key",
417 );
418 assert!(
419 map_field(counters_map, "entities").is_some(),
420 "EventState must keep `entities` as serialized field key",
421 );
422 assert!(
423 map_field(counters_map, "window_start_ms").is_some(),
424 "EventState must keep `window_start_ms` as serialized field key",
425 );
426 }
427
428 #[test]
429 fn entity_summary_serialization_shape_is_stable() {
430 let encoded = to_cbor_value(&EntitySummary {
431 path: "alpha".to_string(),
432 load_calls: 5,
433 delete_calls: 6,
434 rows_loaded: 8,
435 rows_scanned: 9,
436 rows_deleted: 10,
437 avg_rows_per_load: 1.6,
438 avg_rows_scanned_per_load: 1.8,
439 avg_rows_per_delete: 2.0,
440 index_inserts: 11,
441 index_removes: 12,
442 reverse_index_inserts: 13,
443 reverse_index_removes: 14,
444 relation_reverse_lookups: 15,
445 relation_delete_blocks: 16,
446 unique_violations: 17,
447 non_atomic_partial_commits: 18,
448 non_atomic_partial_rows_committed: 19,
449 });
450 let root = expect_cbor_map(&encoded);
451 assert!(
452 map_field(root, "path").is_some(),
453 "EntitySummary must keep `path` as serialized field key",
454 );
455 assert!(
456 map_field(root, "avg_rows_per_load").is_some(),
457 "EntitySummary must keep `avg_rows_per_load` as serialized field key",
458 );
459 assert!(
460 map_field(root, "relation_delete_blocks").is_some(),
461 "EntitySummary must keep `relation_delete_blocks` as serialized field key",
462 );
463 assert!(
464 map_field(root, "non_atomic_partial_rows_committed").is_some(),
465 "EntitySummary must keep `non_atomic_partial_rows_committed` as serialized field key",
466 );
467 }
468}