Skip to main content

sova_devtools/
collector.rs

1//! Per-request collector bag + finished snapshot.
2
3use serde::Serialize;
4use std::sync::{Arc, Mutex};
5use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
6
7#[derive(Clone, Debug, Serialize)]
8pub struct LogLine {
9    pub level: String,
10    pub target: String,
11    pub message: String,
12    pub request_id: Option<String>,
13    pub at_ms: u64,
14}
15
16#[derive(Clone, Debug, Serialize)]
17pub struct QueryLine {
18    pub sql: String,
19    pub duration_ms: Option<f64>,
20    pub rows: Option<u64>,
21}
22
23#[derive(Clone, Debug, Serialize)]
24pub struct HttpLine {
25    pub method: String,
26    pub url: String,
27    pub status: Option<u16>,
28    pub duration_ms: Option<f64>,
29    pub error: Option<String>,
30}
31
32#[derive(Clone, Debug, Serialize)]
33pub struct MailLine {
34    pub to: Vec<String>,
35    pub subject: String,
36    pub backend: String,
37}
38
39#[derive(Clone, Debug, Serialize)]
40pub struct JobLine {
41    pub name: String,
42    pub status: String,
43    pub detail: Option<String>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub duration_ms: Option<f64>,
46}
47
48#[derive(Clone, Debug, Serialize)]
49pub struct CacheLine {
50    /// `get` / `set` / `remember` / `remove` / `incr` / redis cmd
51    pub op: String,
52    pub key: String,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub hit: Option<bool>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub bytes: Option<u64>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub duration_ms: Option<f64>,
59    /// `cache` | `kv` | `redis`
60    pub backend: String,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub ok: Option<bool>,
63}
64
65#[derive(Clone, Debug, Default, Serialize)]
66pub struct RouteSnap {
67    pub path: String,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub pattern: Option<String>,
70    #[serde(default, skip_serializing_if = "Vec::is_empty")]
71    pub captures: Vec<(String, String)>,
72}
73
74#[derive(Clone, Debug, Default, Serialize)]
75pub struct RateLimitSnap {
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub limit: Option<u64>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub remaining: Option<u64>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub reset: Option<u64>,
82}
83
84#[derive(Clone, Debug, Default, Serialize)]
85pub struct AuthSnap {
86    pub session_id: Option<String>,
87    pub user_id: Option<String>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub email: Option<String>,
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    pub roles: Vec<String>,
92    pub session_keys: Vec<(String, String)>,
93}
94
95#[derive(Clone, Debug, Serialize)]
96pub struct RequestSnapshot {
97    pub id: String,
98    pub request_id: String,
99    pub method: String,
100    pub path: String,
101    pub status: u16,
102    pub duration_ms: f64,
103    pub at_ms: u64,
104    pub logs: Vec<LogLine>,
105    pub queries: Vec<QueryLine>,
106    pub http: Vec<HttpLine>,
107    pub mail: Vec<MailLine>,
108    pub jobs: Vec<JobLine>,
109    pub cache: Vec<CacheLine>,
110    pub auth: AuthSnap,
111    pub route: RouteSnap,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub locale: Option<String>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub csrf: Option<bool>,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub rate_limit: Option<RateLimitSnap>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub encoding: Option<String>,
120}
121
122#[derive(Clone, Debug, Serialize)]
123pub struct RequestMeta {
124    pub id: String,
125    pub request_id: String,
126    pub method: String,
127    pub path: String,
128    pub status: u16,
129    pub duration_ms: f64,
130    pub at_ms: u64,
131    pub sql_count: usize,
132    pub log_errors: usize,
133    pub http_count: usize,
134    pub mail_count: usize,
135    pub cache_count: usize,
136    pub job_count: usize,
137}
138
139impl From<&RequestSnapshot> for RequestMeta {
140    fn from(s: &RequestSnapshot) -> Self {
141        Self {
142            id: s.id.clone(),
143            request_id: s.request_id.clone(),
144            method: s.method.clone(),
145            path: s.path.clone(),
146            status: s.status,
147            duration_ms: s.duration_ms,
148            at_ms: s.at_ms,
149            sql_count: s.queries.len(),
150            log_errors: s
151                .logs
152                .iter()
153                .filter(|l| l.level.eq_ignore_ascii_case("ERROR") || l.level == "ERROR")
154                .count(),
155            http_count: s.http.len(),
156            mail_count: s.mail.len(),
157            cache_count: s.cache.len(),
158            job_count: s.jobs.len(),
159        }
160    }
161}
162
163#[derive(Default)]
164struct BagInner {
165    logs: Vec<LogLine>,
166    queries: Vec<QueryLine>,
167    http: Vec<HttpLine>,
168    mail: Vec<MailLine>,
169    jobs: Vec<JobLine>,
170    cache: Vec<CacheLine>,
171    auth: AuthSnap,
172    route: RouteSnap,
173    locale: Option<String>,
174    csrf: Option<bool>,
175    rate_limit: Option<RateLimitSnap>,
176    encoding: Option<String>,
177}
178
179/// Per-request collection bag (stored on Request extensions).
180#[derive(Clone)]
181pub struct DevToolsBag {
182    pub id: String,
183    pub request_id: String,
184    pub method: String,
185    pub path: String,
186    pub started: Instant,
187    inner: Arc<Mutex<BagInner>>,
188}
189
190impl DevToolsBag {
191    pub fn new(id: String, request_id: String, method: String, path: String) -> Self {
192        Self {
193            id,
194            request_id,
195            method,
196            path: path.clone(),
197            started: Instant::now(),
198            inner: Arc::new(Mutex::new(BagInner {
199                route: RouteSnap {
200                    path,
201                    ..Default::default()
202                },
203                ..Default::default()
204            })),
205        }
206    }
207
208    pub fn push_log(&self, line: LogLine) {
209        let mut g = self.inner.lock().unwrap();
210        if g.logs.len() < 200 {
211            g.logs.push(line);
212        }
213    }
214
215    pub fn push_query(&self, q: QueryLine) {
216        let mut g = self.inner.lock().unwrap();
217        if g.queries.len() < 200 {
218            g.queries.push(q);
219        }
220    }
221
222    pub fn push_http(&self, h: HttpLine) {
223        let mut g = self.inner.lock().unwrap();
224        if g.http.len() < 100 {
225            g.http.push(h);
226        }
227    }
228
229    pub fn push_mail(&self, m: MailLine) {
230        let mut g = self.inner.lock().unwrap();
231        if g.mail.len() < 50 {
232            g.mail.push(m);
233        }
234    }
235
236    pub fn push_job(&self, j: JobLine) {
237        let mut g = self.inner.lock().unwrap();
238        if g.jobs.len() < 50 {
239            g.jobs.push(j);
240        }
241    }
242
243    pub fn push_cache(&self, c: CacheLine) {
244        let mut g = self.inner.lock().unwrap();
245        if g.cache.len() < 200 {
246            g.cache.push(c);
247        }
248    }
249
250    pub fn set_auth(&self, auth: AuthSnap) {
251        self.inner.lock().unwrap().auth = auth;
252    }
253
254    pub fn set_route(&self, route: RouteSnap) {
255        self.inner.lock().unwrap().route = route;
256    }
257
258    pub fn set_locale(&self, locale: Option<String>) {
259        self.inner.lock().unwrap().locale = locale;
260    }
261
262    pub fn set_csrf(&self, present: Option<bool>) {
263        self.inner.lock().unwrap().csrf = present;
264    }
265
266    pub fn set_rate_limit(&self, rl: Option<RateLimitSnap>) {
267        self.inner.lock().unwrap().rate_limit = rl;
268    }
269
270    pub fn set_encoding(&self, encoding: Option<String>) {
271        self.inner.lock().unwrap().encoding = encoding;
272    }
273
274    pub fn finish(self, status: u16) -> RequestSnapshot {
275        let duration_ms = self.started.elapsed().as_secs_f64() * 1000.0;
276        let at_ms = SystemTime::now()
277            .duration_since(UNIX_EPOCH)
278            .unwrap_or(Duration::ZERO)
279            .as_millis() as u64;
280        let inner = self.inner.lock().unwrap();
281        RequestSnapshot {
282            id: self.id,
283            request_id: self.request_id,
284            method: self.method,
285            path: self.path,
286            status,
287            duration_ms,
288            at_ms,
289            logs: inner.logs.clone(),
290            queries: inner.queries.clone(),
291            http: inner.http.clone(),
292            mail: inner.mail.clone(),
293            jobs: inner.jobs.clone(),
294            cache: inner.cache.clone(),
295            auth: inner.auth.clone(),
296            route: inner.route.clone(),
297            locale: inner.locale.clone(),
298            csrf: inner.csrf,
299            rate_limit: inner.rate_limit.clone(),
300            encoding: inner.encoding.clone(),
301        }
302    }
303}
304
305pub fn now_ms() -> u64 {
306    SystemTime::now()
307        .duration_since(UNIX_EPOCH)
308        .unwrap_or(Duration::ZERO)
309        .as_millis() as u64
310}
311
312/// Truncate keys for safe display / logs.
313pub fn truncate_key(key: &str, max: usize) -> String {
314    if key.len() <= max {
315        key.to_string()
316    } else {
317        format!("{}…", &key[..max.saturating_sub(1)])
318    }
319}