1use rustlavel_core::events::Event;
14use rustlavel_core::Json;
15use std::sync::{Arc, Mutex};
16use std::time::{Duration, Instant};
17
18tokio::task_local! {
19 static CURRENT: Arc<Mutex<Collected>>;
21}
22
23#[derive(Debug, Clone)]
25pub struct Timing {
26 pub kind: String,
27 pub label: String,
29 pub duration: Option<Duration>,
30 pub fields: Vec<(String, String)>,
32}
33
34#[derive(Debug, Default, Clone)]
36pub struct Collected {
37 pub timings: Vec<Timing>,
38 pub started: Option<Instant>,
39}
40
41impl Collected {
42 pub fn of(&self, kind: &str) -> Vec<&Timing> {
44 self.timings.iter().filter(|timing| timing.kind == kind).collect()
45 }
46
47 pub fn total(&self, kind: &str) -> Duration {
48 self.of(kind).iter().filter_map(|timing| timing.duration).sum()
49 }
50
51 pub fn repeated_queries(&self) -> Vec<(String, usize)> {
57 let mut counts: Vec<(String, usize)> = Vec::new();
58
59 for query in self.of("db.query") {
60 match counts.iter_mut().find(|(sql, _)| *sql == query.label) {
61 Some((_, count)) => *count += 1,
62 None => counts.push((query.label.clone(), 1)),
63 }
64 }
65
66 counts.retain(|(_, count)| *count > 1);
67 counts.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
69 counts
70 }
71}
72
73pub struct Collector;
75
76impl Collector {
77 pub fn install() {
88 static ONCE: std::sync::Once = std::sync::Once::new();
89 ONCE.call_once(|| {
90 rustlavel_core::events::subscribe(|event: &Event| {
91 let _ = CURRENT.try_with(|collected| {
92 if let Ok(mut collected) = collected.lock() {
95 collected.timings.push(timing_from(event));
96 }
97 });
98 });
99 });
100 }
101
102 pub async fn collect<F, T>(work: F) -> (T, Collected)
104 where
105 F: std::future::Future<Output = T>,
106 {
107 let collected = Arc::new(Mutex::new(Collected {
108 timings: Vec::new(),
109 started: Some(Instant::now()),
110 }));
111 let handle = Arc::clone(&collected);
112
113 let result = CURRENT.scope(collected, work).await;
114 let gathered = handle
115 .lock()
116 .map(|inner| inner.clone())
117 .unwrap_or_else(|poisoned| poisoned.into_inner().clone());
118 (result, gathered)
119 }
120}
121
122fn label_for(event: &Event) -> String {
124 let field = |name: &str| event.field(name).and_then(Json::as_str).unwrap_or("").to_string();
125
126 match event.kind {
127 "db.query" => field("sql"),
128 "cache.hit" | "cache.miss" => field("key"),
129 "http.client" => format!("{} {}", field("method"), field("url")),
130 "mail.sent" => field("to"),
131 "ai.call" => format!("{} {}", field("provider"), field("model")),
132 "mcp.call" => field("tool"),
133 "queue.pushed" | "queue.processed" | "queue.failed" => field("job"),
134 _ => event
135 .fields
136 .iter()
137 .next()
138 .map(|(key, value)| format!("{key}={}", brief(value)))
139 .unwrap_or_default(),
140 }
141}
142
143fn timing_from(event: &Event) -> Timing {
144 Timing {
145 kind: event.kind.to_string(),
146 label: label_for(event),
147 duration: event.duration,
148 fields: event
149 .fields
150 .iter()
151 .filter(|(key, _)| !is_sensitive(key))
152 .map(|(key, value)| (key.clone(), brief(value)))
153 .collect(),
154 }
155}
156
157fn is_sensitive(key: &str) -> bool {
164 const MARKERS: &[&str] = &[
165 "password", "passwd", "secret", "token", "authorization", "api_key", "apikey",
166 "api-key", "credential", "private_key", "session", "cookie",
167 ];
168 let lowered = key.to_ascii_lowercase();
169 MARKERS.iter().any(|marker| lowered.contains(marker))
170}
171
172fn brief(value: &Json) -> String {
174 let text = match value {
175 Json::String(text) => text.clone(),
176 other => other.to_string(),
177 };
178 if text.chars().count() > 200 {
179 format!("{}…", text.chars().take(200).collect::<String>())
180 } else {
181 text
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 fn query(sql: &str, ms: u64) -> Timing {
190 Timing {
191 kind: "db.query".into(),
192 label: sql.into(),
193 duration: Some(Duration::from_millis(ms)),
194 fields: Vec::new(),
195 }
196 }
197
198 #[test]
199 fn totals_only_the_kind_asked_for() {
200 let collected = Collected {
201 timings: vec![
202 query("select 1", 3),
203 query("select 2", 4),
204 Timing {
205 kind: "cache.hit".into(),
206 label: "users:1".into(),
207 duration: Some(Duration::from_millis(50)),
208 fields: Vec::new(),
209 },
210 ],
211 started: None,
212 };
213
214 assert_eq!(collected.of("db.query").len(), 2);
215 assert_eq!(collected.total("db.query"), Duration::from_millis(7));
216 assert_eq!(collected.total("cache.hit"), Duration::from_millis(50));
217 }
218
219 #[test]
220 fn finds_the_query_that_repeats() {
221 let mut timings = vec![query("select * from posts", 2)];
224 for _ in 0..30 {
225 timings.push(query("select * from users where id = ?", 1));
226 }
227 let collected = Collected { timings, started: None };
228
229 let repeated = collected.repeated_queries();
230 assert_eq!(repeated.len(), 1, "only the repeated one is reported");
231 assert_eq!(repeated[0], ("select * from users where id = ?".to_string(), 30));
232 }
233
234 #[test]
235 fn a_query_run_once_is_not_reported_as_repeated() {
236 let collected =
237 Collected { timings: vec![query("select 1", 1), query("select 2", 1)], started: None };
238
239 assert!(collected.repeated_queries().is_empty());
240 }
241
242 #[test]
243 fn sensitive_field_names_never_reach_the_page() {
244 for key in [
248 "password", "PASSWORD", "api_key", "authorization", "x-api-key",
249 "session_id", "cookie", "db_credential", "private_key",
250 ] {
251 assert!(is_sensitive(key), "{key} should be withheld");
252 }
253 for key in ["sql", "rows", "duration_ms", "route", "status", "key"] {
254 assert!(!is_sensitive(key), "{key} is not sensitive and is worth showing");
255 }
256 }
257
258 #[test]
259 fn a_long_value_is_cut_rather_than_filling_the_page() {
260 let long = Json::String("x".repeat(5_000));
261 let shown = brief(&long);
262
263 assert!(shown.chars().count() <= 201, "got {} characters", shown.chars().count());
264 assert!(shown.ends_with('…'));
265 }
266
267 #[tokio::test]
268 async fn collects_what_happened_inside_the_scope_and_nothing_outside_it() {
269 Collector::install();
270
271 Event::new("db.query").with("sql", "select 'before'").dispatch();
273
274 let ((), collected) = Collector::collect(async {
275 Event::new("db.query").with("sql", "select 'inside'").dispatch();
276 Event::new("cache.hit").with("key", "users:1").dispatch();
277 })
278 .await;
279
280 Event::new("db.query").with("sql", "select 'after'").dispatch();
281
282 let queries = collected.of("db.query");
283 assert_eq!(queries.len(), 1, "only the query inside the scope");
284 assert_eq!(queries[0].label, "select 'inside'");
285 assert_eq!(collected.of("cache.hit").len(), 1);
286 }
287
288 #[tokio::test]
289 async fn two_requests_do_not_see_each_other() {
290 Collector::install();
293
294 let first = Collector::collect(async {
295 Event::new("db.query").with("sql", "first").dispatch();
296 tokio::task::yield_now().await;
297 Event::new("db.query").with("sql", "first again").dispatch();
298 });
299
300 let second = Collector::collect(async {
301 Event::new("db.query").with("sql", "second").dispatch();
302 tokio::task::yield_now().await;
303 });
304
305 let (((), one), ((), two)) = tokio::join!(first, second);
306
307 assert_eq!(one.of("db.query").len(), 2);
308 assert_eq!(two.of("db.query").len(), 1);
309 assert_eq!(two.of("db.query")[0].label, "second");
310 }
311}