1use crate::{
8 InternalError,
9 config::{Config, RoleRuntimeConfig},
10 domain::public_metrics::PublicMetricFamily,
11 dto::{
12 metrics::MetricValue,
13 page::{Page, PageRequest},
14 public_status::{
15 PublicCounterDelta, PublicHealth, PublicHealthStatus, PublicHistoryPoint,
16 PublicHistoryRequest, PublicHistorySnapshot, PublicMetric, PublicMetricKind,
17 PublicMetricsRequest, PublicMetricsSnapshot, PublicSnapshotState,
18 },
19 },
20 model::public_metrics::{
21 MAX_HISTORY_BYTES, MAX_HISTORY_SERIES, MAX_PUBLIC_METRIC_TEXT_BYTES, MAX_PUBLIC_METRICS,
22 PUBLIC_HISTORY_RETENTION_NS, PUBLIC_HISTORY_SLOTS, PUBLIC_METRICS_CADENCE_NS,
23 PUBLIC_METRICS_STALE_AFTER_NS, PublicHistoryCache, PublicMetricSample, PublicMetricsCache,
24 },
25 ops::{
26 ic::IcOps,
27 runtime::{env::EnvOps, metrics},
28 },
29};
30use std::{cell::Cell, collections::BTreeSet};
31
32thread_local! {
33 static APPLICATION_SAMPLER: Cell<Option<ApplicationMetricsSampler>> = const { Cell::new(None) };
34}
35
36#[cfg(feature = "sharding")]
37use crate::ops::storage::placement::sharding::ShardingRegistryOps;
38
39#[derive(Clone, Copy)]
42pub struct ApplicationMetricsSampler {
43 collect: fn() -> Result<Vec<PublicMetric>, crate::dto::error::Error>,
44}
45
46impl ApplicationMetricsSampler {
47 #[must_use]
49 pub const fn new(collect: fn() -> Result<Vec<PublicMetric>, crate::dto::error::Error>) -> Self {
50 Self { collect }
51 }
52}
53
54pub struct PublicMetricsOps;
56
57impl PublicMetricsOps {
58 pub fn set_application_sampler(sample: Option<ApplicationMetricsSampler>) {
60 APPLICATION_SAMPLER.set(sample);
61 }
62
63 #[must_use]
64 pub fn enabled() -> BTreeSet<PublicMetricFamily> {
65 RoleRuntimeConfig::try_get()
66 .map(|config| config.public_metrics.clone())
67 .or_else(|| {
68 Config::get()
69 .ok()
70 .map(|config| config.public_metrics.clone())
71 })
72 .unwrap_or_default()
73 }
74
75 #[must_use]
76 pub fn health() -> PublicHealth {
77 let now = IcOps::now_nanos();
78 PublicHealth {
79 canister_id: IcOps::canister_self(),
80 role: EnvOps::canister_role().ok().map(|role| role.to_string()),
81 health: PublicHealthStatus::Responding,
82 observed_at_ns: now,
83 }
84 }
85
86 #[must_use]
87 pub fn read(request: PublicMetricsRequest) -> PublicMetricsSnapshot {
88 Self::project(request, &Self::enabled(), IcOps::now_nanos())
89 }
90
91 fn project(
92 request: PublicMetricsRequest,
93 enabled: &BTreeSet<PublicMetricFamily>,
94 now_ns: u64,
95 ) -> PublicMetricsSnapshot {
96 let snapshot = enabled
97 .contains(&request.family)
98 .then(|| PublicMetricsCache::snapshot(request.family))
99 .flatten();
100 let state = if !enabled.contains(&request.family) {
101 PublicSnapshotState::Disabled
102 } else if let Some(snapshot) = &snapshot {
103 if now_ns.saturating_sub(snapshot.sampled_at_ns) > PUBLIC_METRICS_STALE_AFTER_NS {
104 PublicSnapshotState::Stale
105 } else {
106 PublicSnapshotState::Fresh
107 }
108 } else {
109 PublicSnapshotState::Unavailable
110 };
111 let sampled_at_ns = snapshot.as_ref().map(|s| s.sampled_at_ns);
112 let truncated = snapshot.as_ref().is_some_and(|s| s.truncated);
113 let rows = snapshot.map_or_else(Vec::new, |s| {
114 s.metrics
115 .into_iter()
116 .map(|row| PublicMetric {
117 name: row.name,
118 canister_id: row.canister_id,
119 value: row.value,
120 unit: row.unit,
121 observed_at_ns: row.observed_at_ns,
122 kind: row.kind,
123 })
124 .collect()
125 });
126 PublicMetricsSnapshot {
127 family: request.family,
128 state,
129 sampled_at_ns,
130 stale_after_ns: PUBLIC_METRICS_STALE_AFTER_NS,
131 truncated,
132 metrics: page(rows, request.page),
133 }
134 }
135
136 pub fn expire_history(now_ns: u64) {
138 PublicHistoryCache::expire(now_ns);
139 }
140
141 #[must_use]
143 pub fn history(request: PublicHistoryRequest) -> PublicHistorySnapshot {
144 let mut snapshot = Self::project_history(request, &Self::enabled(), IcOps::now_nanos());
145 snapshot.canister_version = ic_cdk::api::canister_version();
146 snapshot
147 }
148
149 fn project_history(
150 request: PublicHistoryRequest,
151 enabled: &BTreeSet<PublicMetricFamily>,
152 now_ns: u64,
153 ) -> PublicHistorySnapshot {
154 let selected = enabled.contains(&request.family);
155 let valid_name = request.name.len() <= MAX_PUBLIC_METRIC_TEXT_BYTES;
156 let series = (selected && valid_name)
157 .then(|| PublicHistoryCache::series(request.family, request.name, request.canister_id))
158 .flatten();
159 let slot = now_ns / PUBLIC_METRICS_CADENCE_NS;
160 let mut points: Vec<_> = series.as_ref().map_or_else(Vec::new, |series| {
161 series
162 .slots
163 .iter()
164 .filter(|point| {
165 point.slot <= slot && slot - point.slot < PUBLIC_HISTORY_SLOTS as u64
166 })
167 .copied()
168 .collect()
169 });
170 points.sort_by_key(|point| point.slot);
171 let state = if !selected {
172 PublicSnapshotState::Disabled
173 } else if let Some(point) = points.last() {
174 if now_ns.saturating_sub(point.observed_at_ns) > PUBLIC_METRICS_STALE_AFTER_NS {
175 PublicSnapshotState::Stale
176 } else {
177 PublicSnapshotState::Fresh
178 }
179 } else {
180 PublicSnapshotState::Unavailable
181 };
182 let coverage_start_ns = points
183 .first()
184 .map(|point| point.slot * PUBLIC_METRICS_CADENCE_NS);
185 let total = points.len() as u64;
186 let entries = points
187 .iter()
188 .enumerate()
189 .map(|(index, point)| {
190 let delta = index
191 .checked_sub(1)
192 .and_then(|previous| counter_delta(&points[previous], point));
193 PublicHistoryPoint {
194 delta,
195 slot_start_ns: point.slot * PUBLIC_METRICS_CADENCE_NS,
196 observed_at_ns: point.observed_at_ns,
197 value: point.value,
198 kind: point.kind,
199 }
200 })
201 .skip(usize::try_from(request.page.offset.min(total)).unwrap_or(PUBLIC_HISTORY_SLOTS))
202 .take(
203 usize::try_from(request.page.limit.min(PUBLIC_HISTORY_SLOTS as u64))
204 .unwrap_or(PUBLIC_HISTORY_SLOTS),
205 )
206 .collect();
207 PublicHistorySnapshot {
208 state,
209 unit: series.map(|series| series.unit),
210 heap_started_at_ns: selected
211 .then(PublicHistoryCache::heap_started_at_ns)
212 .flatten(),
213 canister_version: 0,
214 coverage_start_ns,
215 cadence_ns: PUBLIC_METRICS_CADENCE_NS,
216 retention_ns: PUBLIC_HISTORY_RETENTION_NS,
217 stale_after_ns: PUBLIC_METRICS_STALE_AFTER_NS,
218 truncated: selected && PublicHistoryCache::truncated(),
219 series_limit: MAX_HISTORY_SERIES as u64,
220 byte_limit: MAX_HISTORY_BYTES as u64,
221 reserved_bytes: if selected {
222 PublicHistoryCache::reserved_bytes() as u64
223 } else {
224 0
225 },
226 points: Page { entries, total },
227 }
228 }
229
230 pub fn record_application(metrics: Vec<PublicMetric>) -> Result<(), InternalError> {
231 if !Self::enabled().contains(&PublicMetricFamily::Application) {
232 return Ok(());
233 }
234 let rows = metrics.into_iter().map(|row| PublicMetricSample {
235 name: row.name,
236 canister_id: row.canister_id,
237 value: row.value,
238 unit: row.unit,
239 observed_at_ns: row.observed_at_ns,
240 kind: row.kind,
241 });
242 PublicMetricsCache::replace(PublicMetricFamily::Application, IcOps::now_nanos(), rows)
243 }
244
245 pub fn sample_family(family: PublicMetricFamily, now: u64) -> Result<(), InternalError> {
246 let mut rows = match family {
247 PublicMetricFamily::Application => {
248 if let Some(sample) = APPLICATION_SAMPLER.get() {
249 let metrics = (sample.collect)().map_err(|_| InternalError::invalid_input())?;
250 return Self::record_application(metrics);
251 }
252 return Ok(());
253 }
254 PublicMetricFamily::Cycles => vec![PublicMetricSample {
255 name: "balance".into(),
256 canister_id: Some(IcOps::canister_self()),
257 value: IcOps::canister_cycle_balance().to_u128(),
258 unit: "cycles".into(),
259 observed_at_ns: 0,
260 kind: crate::domain::public_metrics::PublicMetricKind::Gauge,
261 }],
262 PublicMetricFamily::Operations => operation_metrics()?,
263 PublicMetricFamily::Performance => performance_metrics()?,
264 PublicMetricFamily::ShardOccupancy => shard_metrics(),
265 };
266 for row in &mut rows {
267 row.observed_at_ns = now;
268 let counter = (family == PublicMetricFamily::Operations
271 && !row.name.starts_with("cycles_funding.icp_refill."))
272 || (family == PublicMetricFamily::Performance
273 && !row.name.starts_with("perf.timer."));
274 if counter {
275 row.kind = PublicMetricKind::Counter {
276 window_id: 0,
277 saturated: if matches!(row.unit.as_str(), "cycles" | "icp_e8s") {
278 row.value == u128::MAX
279 } else {
280 row.value == u128::from(u64::MAX)
281 },
282 };
283 }
284 }
285 PublicMetricsCache::replace(family, now, rows)
286 }
287}
288
289fn counter_delta(
290 previous: &crate::model::public_metrics::PublicHistorySample,
291 current: &crate::model::public_metrics::PublicHistorySample,
292) -> Option<PublicCounterDelta> {
293 let PublicMetricKind::Counter {
294 window_id,
295 saturated: false,
296 } = previous.kind
297 else {
298 return None;
299 };
300 if current.kind
301 != (PublicMetricKind::Counter {
302 window_id,
303 saturated: false,
304 })
305 || previous.slot.checked_add(1) != Some(current.slot)
306 {
307 return None;
308 }
309 let elapsed_ns = current
310 .observed_at_ns
311 .checked_sub(previous.observed_at_ns)
312 .filter(|elapsed| *elapsed > 0)?;
313 Some(PublicCounterDelta {
314 amount: current.value.checked_sub(previous.value)?,
315 elapsed_ns,
316 })
317}
318
319fn page(rows: Vec<PublicMetric>, request: PageRequest) -> Page<PublicMetric> {
320 let total = u64::try_from(rows.len()).unwrap_or(u64::MAX);
321 let start = usize::try_from(request.offset.min(total)).unwrap_or(rows.len());
322 let limit = usize::try_from(request.limit.min(total)).unwrap_or(rows.len());
323 Page {
324 entries: rows.into_iter().skip(start).take(limit).collect(),
325 total,
326 }
327}
328
329fn metric_name(labels: &[String], suffix_bytes: usize) -> Result<String, InternalError> {
331 let bytes = labels.iter().try_fold(
332 suffix_bytes + labels.len().saturating_sub(1),
333 |bytes, label| bytes.checked_add(label.len()),
334 );
335 if bytes.is_none_or(|bytes| bytes > MAX_PUBLIC_METRIC_TEXT_BYTES) {
336 return Err(InternalError::invalid_input());
337 }
338 Ok(labels.join("."))
339}
340
341fn operation_metrics() -> Result<Vec<PublicMetricSample>, InternalError> {
342 metrics::bounded_core_entries(MAX_PUBLIC_METRICS + 1)?
343 .into_iter()
344 .take(MAX_PUBLIC_METRICS + 1)
345 .map(|row| {
346 let suffix_bytes = if matches!(&row.value, MetricValue::CountAndU64 { .. }) {
347 6
348 } else {
349 0
350 };
351 let name = metric_name(&row.labels, suffix_bytes)?;
352 let amount_unit = if row.labels.iter().any(|label| label == "amount_e8s") {
353 "icp_e8s"
354 } else {
355 "cycles"
356 };
357 Ok(match row.value {
358 MetricValue::Count(value) => vec![PublicMetricSample {
359 name,
360 canister_id: row.principal,
361 value: u128::from(value),
362 unit: "count".into(),
363 observed_at_ns: 0,
364 kind: crate::domain::public_metrics::PublicMetricKind::Gauge,
365 }],
366 MetricValue::U128(value) => vec![PublicMetricSample {
367 name,
368 canister_id: row.principal,
369 value,
370 unit: amount_unit.into(),
371 observed_at_ns: 0,
372 kind: crate::domain::public_metrics::PublicMetricKind::Gauge,
373 }],
374 MetricValue::CountAndU64 { count, value_u64 } => vec![
375 PublicMetricSample {
376 name: format!("{name}.count"),
377 canister_id: row.principal,
378 value: u128::from(count),
379 unit: "count".into(),
380 observed_at_ns: 0,
381 kind: crate::domain::public_metrics::PublicMetricKind::Gauge,
382 },
383 PublicMetricSample {
384 name,
385 canister_id: row.principal,
386 value: u128::from(value_u64),
387 unit: "value".into(),
388 observed_at_ns: 0,
389 kind: crate::domain::public_metrics::PublicMetricKind::Gauge,
390 },
391 ],
392 })
393 })
394 .collect::<Result<Vec<_>, InternalError>>()
395 .map(|rows| rows.into_iter().flatten().collect())
396}
397
398fn performance_metrics() -> Result<Vec<PublicMetricSample>, InternalError> {
399 metrics::bounded_performance_entries(MAX_PUBLIC_METRICS / 2 + 1)?
400 .into_iter()
401 .take(MAX_PUBLIC_METRICS / 2 + 1)
402 .map(|row| {
403 let MetricValue::CountAndU64 { count, value_u64 } = row.value else {
404 return Ok(Vec::new());
405 };
406 let name = metric_name(&row.labels, 6)?;
407 Ok(vec![
408 PublicMetricSample {
409 name: format!("{name}.calls"),
410 canister_id: None,
411 value: u128::from(count),
412 unit: "count".into(),
413 observed_at_ns: 0,
414 kind: crate::domain::public_metrics::PublicMetricKind::Gauge,
415 },
416 PublicMetricSample {
417 name,
418 canister_id: None,
419 value: u128::from(value_u64),
420 unit: "instructions".into(),
421 observed_at_ns: 0,
422 kind: crate::domain::public_metrics::PublicMetricKind::Gauge,
423 },
424 ])
425 })
426 .collect::<Result<Vec<_>, InternalError>>()
427 .map(|rows| rows.into_iter().flatten().collect())
428}
429
430#[cfg(feature = "sharding")]
431fn shard_metrics() -> Vec<PublicMetricSample> {
432 ShardingRegistryOps::bounded_registry_entries(MAX_PUBLIC_METRICS / 2 + 1)
433 .into_iter()
434 .flat_map(|row| {
435 vec![
436 PublicMetricSample {
437 name: format!("{}.assigned", row.entry.pool),
438 canister_id: Some(row.pid),
439 value: u128::from(row.entry.count),
440 unit: "assignments".into(),
441 observed_at_ns: 0,
442 kind: crate::domain::public_metrics::PublicMetricKind::Gauge,
443 },
444 PublicMetricSample {
445 name: format!("{}.capacity", row.entry.pool),
446 canister_id: Some(row.pid),
447 value: u128::from(row.entry.capacity),
448 unit: "assignments".into(),
449 observed_at_ns: 0,
450 kind: crate::domain::public_metrics::PublicMetricKind::Gauge,
451 },
452 ]
453 })
454 .collect()
455}
456#[cfg(not(feature = "sharding"))]
457const fn shard_metrics() -> Vec<PublicMetricSample> {
458 Vec::new()
459}
460
461#[cfg(test)]
465mod tests {
466 use super::*;
467 #[cfg(feature = "sharding")]
468 use crate::ids::CanisterRole;
469 use crate::model::public_metrics::MAX_PUBLIC_METRICS;
470
471 fn request(family: PublicMetricFamily) -> PublicMetricsRequest {
472 PublicMetricsRequest {
473 family,
474 page: PageRequest {
475 limit: 1_000,
476 offset: 0,
477 },
478 }
479 }
480 fn publish(
481 family: PublicMetricFamily,
482 now: u64,
483 rows: impl IntoIterator<Item = PublicMetricSample>,
484 ) -> Result<(), InternalError> {
485 PublicMetricsCache::replace(
486 family,
487 now,
488 rows.into_iter().map(|mut row| {
489 row.observed_at_ns = now;
490 row
491 }),
492 )
493 }
494
495 fn sample(value: u128) -> PublicMetricSample {
496 PublicMetricSample {
497 name: format!("assigned.{value:04}"),
498 canister_id: None,
499 value,
500 unit: "assignments".into(),
501 observed_at_ns: 0,
502 kind: crate::domain::public_metrics::PublicMetricKind::Gauge,
503 }
504 }
505
506 #[test]
507 fn publication_is_disabled_even_when_a_cached_snapshot_exists() {
508 let family = PublicMetricFamily::Cycles;
509 publish(family, 10, vec![sample(7)]).unwrap();
510 let result = PublicMetricsOps::project(request(family), &BTreeSet::new(), 10);
511 assert_eq!(result.state, PublicSnapshotState::Disabled);
512 assert_eq!(result.sampled_at_ns, None);
513 assert!(result.metrics.entries.is_empty());
514 }
515
516 #[test]
517 fn reads_preserve_sample_time_and_report_staleness_without_refresh() {
518 let family = PublicMetricFamily::Performance;
519 let enabled = BTreeSet::from([family]);
520 let missing = PublicMetricsOps::project(request(family), &enabled, 10);
521 assert_eq!(missing.state, PublicSnapshotState::Unavailable);
522 publish(family, 10, vec![sample(3)]).unwrap();
523 let fresh = PublicMetricsOps::project(request(family), &enabled, 10);
524 assert_eq!(fresh.state, PublicSnapshotState::Fresh);
525 let stale = PublicMetricsOps::project(
526 request(family),
527 &enabled,
528 11 + PUBLIC_METRICS_STALE_AFTER_NS,
529 );
530 assert_eq!(stale.state, PublicSnapshotState::Stale);
531 assert_eq!(stale.sampled_at_ns, Some(10));
532 assert_eq!(stale.metrics.entries, fresh.metrics.entries);
533 assert_eq!(
534 PublicMetricsCache::snapshot(family).unwrap().sampled_at_ns,
535 10
536 );
537 }
538
539 #[test]
540 fn publication_selection_is_exact_and_pages_are_bounded() {
541 let family = PublicMetricFamily::ShardOccupancy;
542 let enabled = BTreeSet::from([family]);
543 publish(
544 family,
545 20,
546 (0..=MAX_PUBLIC_METRICS).map(|v| sample(v as u128)),
547 )
548 .unwrap();
549 let all = PublicMetricsOps::project(request(family), &enabled, 20);
550 assert!(all.truncated);
551 assert_eq!(all.metrics.entries.len(), MAX_PUBLIC_METRICS);
552 let mut req = request(family);
553 req.page = PageRequest {
554 limit: 2,
555 offset: 1,
556 };
557 let page = PublicMetricsOps::project(req, &enabled, 20);
558 assert_eq!(page.metrics.entries, all.metrics.entries[1..3]);
559 assert_eq!(
560 PublicMetricsOps::project(request(PublicMetricFamily::Operations), &enabled, 20).state,
561 PublicSnapshotState::Disabled
562 );
563 }
564
565 #[test]
566 #[cfg(feature = "sharding")]
567 fn shard_occupancy_samples_assignments_and_capacity_without_keys() {
568 let shard = crate::cdk::types::Principal::from_slice(&[42; 29]);
569 ShardingRegistryOps::clear_for_test();
570 ShardingRegistryOps::create(shard, "demo", 0, &CanisterRole::new("shard"), 4, 0).unwrap();
571 ShardingRegistryOps::assign("demo", "private-key-a", shard).unwrap();
572 ShardingRegistryOps::assign("demo", "private-key-b", shard).unwrap();
573 let family = PublicMetricFamily::ShardOccupancy;
574 let enabled = BTreeSet::from([family]);
575 PublicMetricsOps::sample_family(family, 10).unwrap();
576 let snapshot = PublicMetricsOps::project(request(family), &enabled, 10);
577 assert_eq!(
578 snapshot.metrics.entries,
579 vec![
580 PublicMetric {
581 name: "demo.assigned".into(),
582 canister_id: Some(shard),
583 value: 2,
584 unit: "assignments".into(),
585 observed_at_ns: 10,
586 kind: crate::domain::public_metrics::PublicMetricKind::Gauge,
587 },
588 PublicMetric {
589 name: "demo.capacity".into(),
590 canister_id: Some(shard),
591 value: 4,
592 unit: "assignments".into(),
593 observed_at_ns: 10,
594 kind: crate::domain::public_metrics::PublicMetricKind::Gauge,
595 },
596 ]
597 );
598 ShardingRegistryOps::release("demo", "private-key-a").unwrap();
599 let cached = PublicMetricsOps::project(request(family), &enabled, 11);
600 assert_eq!(cached.metrics.entries, snapshot.metrics.entries);
601 PublicMetricsOps::sample_family(family, 12).unwrap();
602 let refreshed = PublicMetricsOps::project(request(family), &enabled, 12);
603 assert_eq!(refreshed.metrics.entries[0].value, 1);
604 assert_eq!(refreshed.sampled_at_ns, Some(12));
605 ShardingRegistryOps::clear_for_test();
606 }
607
608 #[test]
609 fn rejected_sample_preserves_previous_snapshot() {
610 let family = PublicMetricFamily::Application;
611 publish(family, 10, vec![sample(1)]).unwrap();
612 let mut invalid = sample(2);
613 invalid.name.clear();
614 assert_eq!(
615 publish(family, 20, vec![invalid]).unwrap_err().code(),
616 crate::diagnostics::codes::REQUEST_INVALID
617 );
618 assert_eq!(
619 PublicMetricsCache::snapshot(family).unwrap().sampled_at_ns,
620 10
621 );
622 }
623 #[test]
624 fn cache_consumes_only_one_bounded_prefix_and_reports_truncation() {
625 let consumed = std::cell::Cell::new(0);
626 publish(
627 PublicMetricFamily::Application,
628 10,
629 (0..).map(|value| {
630 consumed.set(consumed.get() + 1);
631 sample(value)
632 }),
633 )
634 .unwrap();
635 assert_eq!(consumed.get(), MAX_PUBLIC_METRICS + 1);
636 let snapshot = PublicMetricsCache::snapshot(PublicMetricFamily::Application).unwrap();
637 assert!(snapshot.truncated);
638 assert_eq!(snapshot.metrics.len(), MAX_PUBLIC_METRICS);
639 }
640
641 #[test]
642 fn performance_sampling_is_bounded_and_independent_of_recording_order() {
643 let family = PublicMetricFamily::Performance;
644 crate::perf::reset();
645 for value in (0..1024).rev() {
646 crate::perf::record_checkpoint("bounded", &format!("sample_{value:04}"), value);
647 }
648 PublicMetricsOps::sample_family(family, 10).unwrap();
649 let first = PublicMetricsOps::project(request(family), &BTreeSet::from([family]), 10);
650 assert!(first.truncated);
651 assert_eq!(first.metrics.entries.len(), MAX_PUBLIC_METRICS);
652 assert_eq!(
653 first.metrics.entries[0].name,
654 "perf.checkpoint.bounded.sample_0000"
655 );
656 crate::perf::reset();
657 for value in 0..1024 {
658 crate::perf::record_checkpoint("bounded", &format!("sample_{value:04}"), value);
659 }
660 PublicMetricsOps::sample_family(family, 20).unwrap();
661 let second = PublicMetricsOps::project(request(family), &BTreeSet::from([family]), 20);
662 for (first, second) in first.metrics.entries.iter().zip(&second.metrics.entries) {
663 assert_eq!(first.name, second.name);
664 assert_eq!(first.value, second.value);
665 assert_eq!(first.kind, second.kind);
666 assert_eq!(first.observed_at_ns, 10);
667 assert_eq!(second.observed_at_ns, 20);
668 }
669 crate::perf::reset();
670 }
671
672 #[test]
673 #[cfg(feature = "sharding")]
674 fn shard_sampling_bounds_registry_visits_and_retained_rows() {
675 ShardingRegistryOps::clear_for_test();
676 for value in 0_u32..300 {
677 let shard = crate::cdk::types::Principal::from_slice(&value.to_be_bytes());
678 ShardingRegistryOps::create(shard, "bounded", value, &CanisterRole::new("shard"), 4, 0)
679 .unwrap();
680 }
681 assert_eq!(
682 ShardingRegistryOps::bounded_registry_entries(129).len(),
683 129
684 );
685 PublicMetricsOps::sample_family(PublicMetricFamily::ShardOccupancy, 10).unwrap();
686 let snapshot = PublicMetricsCache::snapshot(PublicMetricFamily::ShardOccupancy).unwrap();
687 assert!(snapshot.truncated);
688 assert_eq!(snapshot.metrics.len(), MAX_PUBLIC_METRICS);
689 ShardingRegistryOps::clear_for_test();
690 }
691}
692
693#[cfg(test)]
694mod history_tests {
695 use super::*;
696
697 #[test]
698 fn history_reads_bound_pages_hide_disabled_data_and_expire_without_mutation() {
699 let family = PublicMetricFamily::Cycles;
700 for slot in [1, 2, 5] {
701 PublicMetricsCache::replace(
702 family,
703 slot * PUBLIC_METRICS_CADENCE_NS,
704 [PublicMetricSample {
705 name: "balance".into(),
706 canister_id: None,
707 value: slot.into(),
708 unit: "cycles".into(),
709 observed_at_ns: slot * PUBLIC_METRICS_CADENCE_NS,
710 kind: PublicMetricKind::Gauge,
711 }],
712 )
713 .unwrap();
714 }
715 let request = PublicHistoryRequest {
716 family,
717 name: "balance".into(),
718 canister_id: None,
719 page: PageRequest {
720 offset: 1,
721 limit: u64::MAX,
722 },
723 };
724 let enabled = BTreeSet::from([family]);
725 let view = PublicMetricsOps::project_history(
726 request.clone(),
727 &enabled,
728 5 * PUBLIC_METRICS_CADENCE_NS,
729 );
730 assert_eq!(view.points.total, 3);
731 assert_eq!(
732 view.points
733 .entries
734 .iter()
735 .map(|point| point.value)
736 .collect::<Vec<_>>(),
737 [2, 5]
738 );
739 assert_eq!(view.coverage_start_ns, Some(PUBLIC_METRICS_CADENCE_NS));
740 let disabled = PublicMetricsOps::project_history(
741 request.clone(),
742 &BTreeSet::new(),
743 5 * PUBLIC_METRICS_CADENCE_NS,
744 );
745 assert_eq!(disabled.state, PublicSnapshotState::Disabled);
746 assert!(disabled.points.entries.is_empty());
747 assert_eq!(disabled.reserved_bytes, 0);
748 let expired =
749 PublicMetricsOps::project_history(request, &enabled, 400 * PUBLIC_METRICS_CADENCE_NS);
750 assert_eq!(expired.state, PublicSnapshotState::Unavailable);
751 assert!(expired.points.entries.is_empty());
752 assert_eq!(expired.reserved_bytes, view.reserved_bytes);
753 assert_eq!(
754 PublicMetricsCache::snapshot(family).unwrap().sampled_at_ns,
755 5 * PUBLIC_METRICS_CADENCE_NS
756 );
757 }
758}
759
760#[cfg(test)]
761mod counter_tests {
762 use super::*;
763 use crate::model::public_metrics::PublicHistorySample;
764
765 #[test]
766 fn public_metrics_counter_deltas_require_adjacent_unsaturated_same_window_observations() {
767 let first = PublicHistorySample {
768 slot: 1,
769 observed_at_ns: 10,
770 value: 7,
771 kind: PublicMetricKind::Counter {
772 window_id: 4,
773 saturated: false,
774 },
775 };
776 let second = PublicHistorySample {
777 slot: 2,
778 observed_at_ns: 20,
779 value: 12,
780 ..first
781 };
782 assert_eq!(
783 counter_delta(&first, &second),
784 Some(PublicCounterDelta {
785 amount: 5,
786 elapsed_ns: 10
787 })
788 );
789 for incompatible in [
790 PublicHistorySample {
791 kind: PublicMetricKind::Gauge,
792 ..second
793 },
794 PublicHistorySample {
795 kind: PublicMetricKind::Counter {
796 window_id: 5,
797 saturated: false,
798 },
799 ..second
800 },
801 PublicHistorySample {
802 kind: PublicMetricKind::Counter {
803 window_id: 4,
804 saturated: true,
805 },
806 ..second
807 },
808 PublicHistorySample { slot: 3, ..second },
809 PublicHistorySample {
810 observed_at_ns: 10,
811 ..second
812 },
813 PublicHistorySample { value: 1, ..second },
814 ] {
815 assert_eq!(counter_delta(&first, &incompatible), None);
816 }
817 }
818}