canic_core/ops/runtime/
metrics.rs

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