canic_core/ops/runtime/
metrics.rs

1pub use crate::model::metrics::{access::*, endpoint::*, http::*, icc::*, system::*, timer::*};
2use crate::{
3    dto::page::{Page, PageRequest},
4    perf::{PerfKey, entries as perf_entries},
5};
6use candid::CandidType;
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeSet, HashMap};
9
10///
11/// MetricsOps
12/// Thin ops-layer facade over volatile metrics state.
13///
14
15pub struct MetricsOps;
16
17///
18/// EndpointHealthEntry
19/// Derived endpoint-level health view joined at read time.
20///
21
22#[derive(CandidType, Clone, Debug, Deserialize, Serialize)]
23pub struct EndpointHealthEntry {
24    pub endpoint: String,
25    pub attempted: u64,
26    pub denied: u64,
27    pub completed: u64,
28    pub ok: u64,
29    pub err: u64,
30    pub avg_instr: u64,
31    pub total_instr: u64,
32}
33
34impl MetricsOps {
35    /// Export the current metrics snapshot.
36    #[must_use]
37    pub fn system_snapshot() -> SystemMetricsSnapshot {
38        let mut entries = SystemMetrics::snapshot();
39        entries.sort_by(|a, b| a.kind.cmp(&b.kind));
40        entries
41    }
42
43    /// Export the current HTTP metrics snapshot.
44    #[must_use]
45    pub fn http_snapshot() -> HttpMetricsSnapshot {
46        HttpMetrics::snapshot()
47    }
48
49    /// Export the current HTTP metrics snapshot as a stable, paged view.
50    #[must_use]
51    pub fn http_page(request: PageRequest) -> Page<HttpMetricEntry> {
52        let mut entries = Self::http_snapshot();
53        entries.sort_by(|a, b| a.method.cmp(&b.method).then_with(|| a.url.cmp(&b.url)));
54        paginate(entries, request)
55    }
56
57    /// Export the current ICC metrics snapshot.
58    #[must_use]
59    pub fn icc_snapshot() -> IccMetricsSnapshot {
60        IccMetrics::snapshot()
61    }
62
63    /// Export the current ICC metrics snapshot as a stable, paged view.
64    #[must_use]
65    pub fn icc_page(request: PageRequest) -> Page<IccMetricEntry> {
66        let mut entries = Self::icc_snapshot();
67        entries.sort_by(|a, b| {
68            a.target
69                .as_slice()
70                .cmp(b.target.as_slice())
71                .then_with(|| a.method.cmp(&b.method))
72        });
73        paginate(entries, request)
74    }
75
76    /// Export the current timer metrics snapshot.
77    #[must_use]
78    pub fn timer_snapshot() -> TimerMetricsSnapshot {
79        TimerMetrics::snapshot()
80    }
81
82    /// Export the current timer metrics snapshot as a stable, paged view.
83    #[must_use]
84    pub fn timer_page(request: PageRequest) -> Page<TimerMetricEntry> {
85        let mut entries = Self::timer_snapshot();
86        entries.sort_by(|a, b| {
87            a.mode
88                .cmp(&b.mode)
89                .then_with(|| a.delay_ms.cmp(&b.delay_ms))
90                .then_with(|| a.label.cmp(&b.label))
91        });
92        paginate(entries, request)
93    }
94
95    /// Export the current access metrics snapshot.
96    #[must_use]
97    pub fn access_snapshot() -> AccessMetricsSnapshot {
98        AccessMetrics::snapshot()
99    }
100
101    /// Export the current access metrics snapshot as a stable, paged view.
102    #[must_use]
103    pub fn access_page(request: PageRequest) -> Page<AccessMetricEntry> {
104        let mut entries = Self::access_snapshot();
105        entries.sort_by(|a, b| {
106            a.endpoint
107                .cmp(&b.endpoint)
108                .then_with(|| a.kind.cmp(&b.kind))
109        });
110        paginate(entries, request)
111    }
112
113    /// Derived endpoint health view (attempts + denials + results + perf).
114    #[must_use]
115    pub fn endpoint_health_page(request: PageRequest) -> Page<EndpointHealthEntry> {
116        Self::endpoint_health_page_excluding(request, None)
117    }
118
119    /// Derived endpoint health view (attempts + denials + results + perf), optionally excluding an
120    /// endpoint label (useful to avoid self-observation artifacts for the view endpoint itself).
121    #[must_use]
122    pub fn endpoint_health_page_excluding(
123        request: PageRequest,
124        exclude_endpoint: Option<&str>,
125    ) -> Page<EndpointHealthEntry> {
126        let attempt_snapshot = EndpointAttemptMetrics::snapshot();
127        let result_snapshot = EndpointResultMetrics::snapshot();
128        let access_snapshot = AccessMetrics::snapshot();
129        let perf_snapshot = perf_endpoint_snapshot();
130
131        let mut attempts: HashMap<String, (u64, u64)> = HashMap::new();
132        for entry in attempt_snapshot {
133            attempts.insert(entry.endpoint, (entry.attempted, entry.completed));
134        }
135
136        let mut results: HashMap<String, (u64, u64)> = HashMap::new();
137        for entry in result_snapshot {
138            results.insert(entry.endpoint, (entry.ok, entry.err));
139        }
140
141        let mut denied: HashMap<String, u64> = HashMap::new();
142        for entry in access_snapshot {
143            let counter = denied.entry(entry.endpoint).or_insert(0);
144            *counter = counter.saturating_add(entry.count);
145        }
146
147        let mut endpoints = BTreeSet::<String>::new();
148        endpoints.extend(attempts.keys().cloned());
149        endpoints.extend(results.keys().cloned());
150        endpoints.extend(denied.keys().cloned());
151        endpoints.extend(perf_snapshot.keys().cloned());
152
153        let entries = endpoints
154            .into_iter()
155            .filter(|endpoint| match exclude_endpoint {
156                Some(excluded) => endpoint != excluded,
157                None => true,
158            })
159            .map(|endpoint| {
160                let (attempted, completed) = attempts.get(&endpoint).copied().unwrap_or((0, 0));
161
162                // Aggregated access denials (auth + policy), per endpoint.
163                let denied = denied.get(&endpoint).copied().unwrap_or(0);
164                let (ok, err) = results.get(&endpoint).copied().unwrap_or((0, 0));
165
166                let (perf_count, total_instr) =
167                    perf_snapshot.get(&endpoint).copied().unwrap_or((0, 0));
168                let avg_instr = if perf_count == 0 {
169                    0
170                } else {
171                    total_instr / perf_count
172                };
173
174                EndpointHealthEntry {
175                    endpoint,
176                    attempted,
177                    denied,
178                    completed,
179                    ok,
180                    err,
181                    avg_instr,
182                    total_instr,
183                }
184            })
185            .collect::<Vec<_>>();
186
187        paginate(entries, request)
188    }
189}
190
191// -----------------------------------------------------------------------------
192// Pagination
193// -----------------------------------------------------------------------------
194
195#[must_use]
196fn paginate<T>(entries: Vec<T>, request: PageRequest) -> Page<T> {
197    let request = request.clamped();
198    let total = entries.len() as u64;
199    let (start, end) = pagination_bounds(total, request);
200
201    let entries = entries.into_iter().skip(start).take(end - start).collect();
202
203    Page { entries, total }
204}
205
206#[allow(clippy::cast_possible_truncation)]
207fn pagination_bounds(total: u64, request: PageRequest) -> (usize, usize) {
208    let start = request.offset.min(total);
209    let end = request.offset.saturating_add(request.limit).min(total);
210
211    let start = start as usize;
212    let end = end as usize;
213
214    (start, end)
215}
216
217// -----------------------------------------------------------------------------
218// Joins
219// -----------------------------------------------------------------------------
220
221/// perf_endpoint_snapshot
222///
223/// NOTE:
224/// If perf_entries() ever returns multiple entries per endpoint (e.g.:
225/// multiple call sites
226/// multiple timers
227/// future instrumentation changes),
228/// you will silently overwrite earlier values.
229#[must_use]
230fn perf_endpoint_snapshot() -> HashMap<String, (u64, u64)> {
231    let mut out = HashMap::<String, (u64, u64)>::new();
232
233    for entry in perf_entries() {
234        let PerfKey::Endpoint(label) = &entry.key else {
235            continue;
236        };
237
238        let slot = out.entry(label.clone()).or_insert((0, 0));
239        slot.0 = slot.0.saturating_add(entry.count);
240        slot.1 = slot.1.saturating_add(entry.total_instructions);
241    }
242
243    out
244}
245
246///
247/// TESTS
248///
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::perf;
254
255    #[test]
256    fn endpoint_health_joins_tables() {
257        EndpointAttemptMetrics::reset();
258        EndpointResultMetrics::reset();
259        AccessMetrics::reset();
260        perf::reset();
261
262        EndpointAttemptMetrics::increment_attempted("a");
263        EndpointAttemptMetrics::increment_attempted("a");
264        EndpointAttemptMetrics::increment_completed("a");
265        EndpointResultMetrics::increment_ok("a");
266        perf::record_endpoint("a", 1_000);
267
268        EndpointAttemptMetrics::increment_attempted("b");
269        AccessMetrics::increment("b", AccessMetricKind::Auth);
270
271        let page = MetricsOps::endpoint_health_page(PageRequest::new(10, 0));
272        assert_eq!(page.total, 2);
273
274        let a = &page.entries[0];
275        assert_eq!(a.endpoint, "a");
276        assert_eq!(a.attempted, 2);
277        assert_eq!(a.denied, 0);
278        assert_eq!(a.completed, 1);
279        assert_eq!(a.ok, 1);
280        assert_eq!(a.err, 0);
281        assert_eq!(a.total_instr, 1_000);
282        assert_eq!(a.avg_instr, 1_000);
283
284        let b = &page.entries[1];
285        assert_eq!(b.endpoint, "b");
286        assert_eq!(b.attempted, 1);
287        assert_eq!(b.denied, 1);
288        assert_eq!(b.completed, 0);
289        assert_eq!(b.ok, 0);
290        assert_eq!(b.err, 0);
291        assert_eq!(b.total_instr, 0);
292        assert_eq!(b.avg_instr, 0);
293    }
294}