canic_core/model/metrics/
http.rs

1pub use crate::cdk::mgmt::{HttpHeader, HttpMethod, HttpRequestArgs, HttpRequestResult};
2use candid::CandidType;
3use serde::{Deserialize, Serialize};
4use std::{cell::RefCell, collections::HashMap};
5
6thread_local! {
7    static HTTP_METRICS: RefCell<HashMap<HttpMetricKey, u64>> = RefCell::new(HashMap::new());
8}
9
10///
11/// HttpMetricKey
12/// Uniquely identifies an HTTP outcall by method + URL.
13///
14
15#[derive(CandidType, Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
16pub struct HttpMetricKey {
17    pub method: HttpMethod,
18    pub url: String,
19}
20
21///
22/// HttpMetricEntry
23/// Snapshot entry pairing a method/url with its count.
24///
25
26#[derive(CandidType, Clone, Debug, Deserialize, Serialize)]
27pub struct HttpMetricEntry {
28    pub method: HttpMethod,
29    pub url: String,
30    pub count: u64,
31}
32
33///
34/// HttpMetricsSnapshot
35///
36
37pub type HttpMetricsSnapshot = Vec<HttpMetricEntry>;
38
39///
40/// HttpMetrics
41/// Volatile counters for HTTP outcalls keyed by method + URL.
42/// The label is a url override
43///
44
45pub struct HttpMetrics;
46
47impl HttpMetrics {
48    pub fn increment(method: HttpMethod, url: &str) {
49        Self::increment_with_label(method, url, None);
50    }
51
52    pub fn increment_with_label(method: HttpMethod, url: &str, label: Option<&str>) {
53        let label = Self::label_for(url, label);
54
55        HTTP_METRICS.with_borrow_mut(|counts| {
56            let key = HttpMetricKey { method, url: label };
57            let entry = counts.entry(key).or_insert(0);
58            *entry = entry.saturating_add(1);
59        });
60    }
61
62    #[must_use]
63    pub fn snapshot() -> HttpMetricsSnapshot {
64        HTTP_METRICS.with_borrow(|counts| {
65            counts
66                .iter()
67                .map(|(key, count)| HttpMetricEntry {
68                    method: key.method,
69                    url: key.url.clone(),
70                    count: *count,
71                })
72                .collect()
73        })
74    }
75
76    fn label_for(url: &str, label: Option<&str>) -> String {
77        if let Some(label) = label {
78            return label.to_string();
79        }
80
81        Self::normalize(url)
82    }
83
84    fn normalize(url: &str) -> String {
85        let without_fragment = url.split('#').next().unwrap_or(url);
86        let without_query = without_fragment
87            .split('?')
88            .next()
89            .unwrap_or(without_fragment);
90
91        let candidate = without_query.trim();
92        if candidate.is_empty() {
93            url.to_string()
94        } else {
95            candidate.to_string()
96        }
97    }
98
99    #[cfg(test)]
100    pub fn reset() {
101        HTTP_METRICS.with_borrow_mut(HashMap::clear);
102    }
103}
104
105///
106/// TESTS
107///
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn http_metrics_track_method_and_url_normalized() {
115        HttpMetrics::reset();
116
117        HttpMetrics::increment(HttpMethod::GET, "https://example.com/a?query=1#frag");
118        HttpMetrics::increment(HttpMethod::GET, "https://example.com/a?query=2");
119        HttpMetrics::increment(HttpMethod::POST, "https://example.com/a?query=3");
120        HttpMetrics::increment(HttpMethod::GET, "https://example.com/b#x");
121
122        let snapshot = HttpMetrics::snapshot();
123        let mut map: HashMap<(HttpMethod, String), u64> = snapshot
124            .into_iter()
125            .map(|entry| ((entry.method, entry.url), entry.count))
126            .collect();
127
128        assert_eq!(
129            map.remove(&(HttpMethod::GET, "https://example.com/a".to_string())),
130            Some(2)
131        );
132        assert_eq!(
133            map.remove(&(HttpMethod::POST, "https://example.com/a".to_string())),
134            Some(1)
135        );
136        assert_eq!(
137            map.remove(&(HttpMethod::GET, "https://example.com/b".to_string())),
138            Some(1)
139        );
140        assert!(map.is_empty());
141    }
142
143    #[test]
144    fn http_metrics_allow_custom_labels() {
145        HttpMetrics::reset();
146
147        HttpMetrics::increment_with_label(
148            HttpMethod::GET,
149            "https://example.com/search?q=abc",
150            Some("search"),
151        );
152        HttpMetrics::increment_with_label(
153            HttpMethod::GET,
154            "https://example.com/search?q=def",
155            Some("search"),
156        );
157
158        let snapshot = HttpMetrics::snapshot();
159        let mut map: HashMap<(HttpMethod, String), u64> = snapshot
160            .into_iter()
161            .map(|entry| ((entry.method, entry.url), entry.count))
162            .collect();
163
164        assert_eq!(
165            map.remove(&(HttpMethod::GET, "search".to_string())),
166            Some(2)
167        );
168        assert!(map.is_empty());
169    }
170}