canic_core/model/metrics/
http.rs1use 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#[derive(CandidType, Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
15pub struct HttpMetricKey {
16 pub method: String,
17 pub url: String,
18}
19
20#[derive(CandidType, Clone, Debug, Deserialize, Serialize)]
26pub struct HttpMetricEntry {
27 pub method: String,
28 pub url: String,
29 pub count: u64,
30}
31
32pub type HttpMetricsSnapshot = Vec<HttpMetricEntry>;
37
38pub 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#[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}