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;
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#[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}