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/// The label is a url override
42///
43
44pub struct HttpMetrics;
45
46impl HttpMetrics {
47    pub fn increment(method: &str, url: &str) {
48        Self::increment_with_label(method, url, None);
49    }
50
51    pub fn increment_with_label(method: &str, url: &str, label: Option<&str>) {
52        let label = Self::label_for(url, label);
53
54        HTTP_METRICS.with_borrow_mut(|counts| {
55            let key = HttpMetricKey {
56                method: method.to_string(),
57                url: label,
58            };
59            let entry = counts.entry(key).or_insert(0);
60            *entry = entry.saturating_add(1);
61        });
62    }
63
64    #[must_use]
65    pub fn snapshot() -> HttpMetricsSnapshot {
66        HTTP_METRICS.with_borrow(|counts| {
67            counts
68                .iter()
69                .map(|(key, count)| HttpMetricEntry {
70                    method: key.method.clone(),
71                    url: key.url.clone(),
72                    count: *count,
73                })
74                .collect()
75        })
76    }
77
78    fn label_for(url: &str, label: Option<&str>) -> String {
79        if let Some(label) = label {
80            return label.to_string();
81        }
82
83        Self::normalize(url)
84    }
85
86    fn normalize(url: &str) -> String {
87        let without_fragment = url.split('#').next().unwrap_or(url);
88        let without_query = without_fragment
89            .split('?')
90            .next()
91            .unwrap_or(without_fragment);
92
93        let candidate = without_query.trim();
94        if candidate.is_empty() {
95            url.to_string()
96        } else {
97            candidate.to_string()
98        }
99    }
100
101    #[cfg(test)]
102    pub fn reset() {
103        HTTP_METRICS.with_borrow_mut(HashMap::clear);
104    }
105}
106
107///
108/// TESTS
109///
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn http_metrics_track_method_and_url_normalized() {
117        HttpMetrics::reset();
118
119        HttpMetrics::increment("GET", "https://example.com/a?query=1#frag");
120        HttpMetrics::increment("GET", "https://example.com/a?query=2");
121        HttpMetrics::increment("POST", "https://example.com/a?query=3");
122        HttpMetrics::increment("GET", "https://example.com/b#x");
123
124        let snapshot = HttpMetrics::snapshot();
125        let mut map: HashMap<(String, String), u64> = snapshot
126            .into_iter()
127            .map(|entry| ((entry.method, entry.url), entry.count))
128            .collect();
129
130        assert_eq!(
131            map.remove(&("GET".to_string(), "https://example.com/a".to_string())),
132            Some(2)
133        );
134        assert_eq!(
135            map.remove(&("POST".to_string(), "https://example.com/a".to_string())),
136            Some(1)
137        );
138        assert_eq!(
139            map.remove(&("GET".to_string(), "https://example.com/b".to_string())),
140            Some(1)
141        );
142        assert!(map.is_empty());
143    }
144
145    #[test]
146    fn http_metrics_allow_custom_labels() {
147        HttpMetrics::reset();
148
149        HttpMetrics::increment_with_label(
150            "GET",
151            "https://example.com/search?q=abc",
152            Some("search"),
153        );
154        HttpMetrics::increment_with_label(
155            "GET",
156            "https://example.com/search?q=def",
157            Some("search"),
158        );
159
160        let snapshot = HttpMetrics::snapshot();
161        let mut map: HashMap<(String, String), u64> = snapshot
162            .into_iter()
163            .map(|entry| ((entry.method, entry.url), entry.count))
164            .collect();
165
166        assert_eq!(
167            map.remove(&("GET".to_string(), "search".to_string())),
168            Some(2)
169        );
170        assert!(map.is_empty());
171    }
172}