canic_core/model/metrics/
http.rs

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