Skip to main content

canic_core/ops/runtime/public_metrics/
mod.rs

1//! Module: ops::runtime::public_metrics
2//!
3//! Responsibility: collect local aggregate counters and project the publication cache.
4//! Does not own: timers, endpoint authorization, or application metric semantics.
5//! Boundary: public query projection reads cached values only.
6
7use 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/// One synchronous aggregate provider composed by application lifecycle code.
40/// The provider owns bounded source collection; Canic owns the timer and history.
41#[derive(Clone, Copy)]
42pub struct ApplicationMetricsSampler {
43    collect: fn() -> Result<Vec<PublicMetric>, crate::dto::error::Error>,
44}
45
46impl ApplicationMetricsSampler {
47    /// Wrap a provider that reads bounded counters and preserves source time and reset windows.
48    #[must_use]
49    pub const fn new(collect: fn() -> Result<Vec<PublicMetric>, crate::dto::error::Error>) -> Self {
50        Self { collect }
51    }
52}
53
54/// Local sampling and public snapshot projection under immutable publication configuration.
55pub struct PublicMetricsOps;
56
57impl PublicMetricsOps {
58    /// Install the sole synchronous composition callback, with no timer or database ownership.
59    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    /// Expire heap history from the update-side timer, never from a public query.
137    pub fn expire_history(now_ns: u64) {
138        PublicHistoryCache::expire(now_ns);
139    }
140
141    /// Read one bounded cached series without invoking any producer.
142    #[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            // Timers expose lifetime summaries without a per-registration reset identity.
269            // Keep these raw observations out of counter delta/rate calculations.
270            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
329// Validate the complete series identity before allocating its formatted name.
330fn 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// -----------------------------------------------------------------------------
462// Tests
463// -----------------------------------------------------------------------------
464#[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}