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 GraphqlLine {
50    pub operation: String,
51    pub kind: String,
52    pub duration_ms: f64,
53    pub errors: usize,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub auth: Option<bool>,
56}
57
58#[derive(Clone, Debug, Serialize)]
59pub struct GrpcLine {
60    pub method: String,
61    pub base: String,
62    pub direction: String,
63    pub duration_ms: f64,
64    pub ok: bool,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub status: Option<u16>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub error: Option<String>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub bytes_in: Option<u64>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub bytes_out: Option<u64>,
73}
74
75#[derive(Clone, Debug, Serialize)]
76pub struct RabbitLine {
77    pub op: String,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub exchange: Option<String>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub routing_key: Option<String>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub queue: Option<String>,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub bytes: Option<u64>,
86    pub duration_ms: f64,
87    pub ok: bool,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub error: Option<String>,
90}
91
92#[derive(Clone, Debug, Serialize)]
93pub struct CacheLine {
94    /// `get` / `set` / `remember` / `remove` / `incr` / redis cmd
95    pub op: String,
96    pub key: String,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub hit: Option<bool>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub bytes: Option<u64>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub duration_ms: Option<f64>,
103    /// `cache` | `kv` | `redis`
104    pub backend: String,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub ok: Option<bool>,
107}
108
109#[derive(Clone, Debug, Default, Serialize)]
110pub struct RouteSnap {
111    pub path: String,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub pattern: Option<String>,
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub captures: Vec<(String, String)>,
116}
117
118#[derive(Clone, Debug, Default, Serialize)]
119pub struct RateLimitSnap {
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub limit: Option<u64>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub remaining: Option<u64>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub reset: Option<u64>,
126}
127
128#[derive(Clone, Debug, Default, Serialize)]
129pub struct AuthSnap {
130    pub session_id: Option<String>,
131    pub user_id: Option<String>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub email: Option<String>,
134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
135    pub roles: Vec<String>,
136    pub session_keys: Vec<(String, String)>,
137}
138
139#[derive(Clone, Debug, Serialize)]
140pub struct RequestSnapshot {
141    pub id: String,
142    pub request_id: String,
143    pub method: String,
144    pub path: String,
145    pub status: u16,
146    pub duration_ms: f64,
147    pub at_ms: u64,
148    pub logs: Vec<LogLine>,
149    pub queries: Vec<QueryLine>,
150    pub http: Vec<HttpLine>,
151    pub mail: Vec<MailLine>,
152    pub jobs: Vec<JobLine>,
153    pub cache: Vec<CacheLine>,
154    #[serde(default, skip_serializing_if = "Vec::is_empty")]
155    pub graphql: Vec<GraphqlLine>,
156    #[serde(default, skip_serializing_if = "Vec::is_empty")]
157    pub grpc: Vec<GrpcLine>,
158    #[serde(default, skip_serializing_if = "Vec::is_empty")]
159    pub rabbit: Vec<RabbitLine>,
160    pub auth: AuthSnap,
161    pub route: RouteSnap,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub locale: Option<String>,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub csrf: Option<bool>,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub rate_limit: Option<RateLimitSnap>,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub encoding: Option<String>,
170}
171
172#[derive(Clone, Debug, Serialize)]
173pub struct RequestMeta {
174    pub id: String,
175    pub request_id: String,
176    pub method: String,
177    pub path: String,
178    pub status: u16,
179    pub duration_ms: f64,
180    pub at_ms: u64,
181    pub sql_count: usize,
182    pub log_errors: usize,
183    pub http_count: usize,
184    pub mail_count: usize,
185    pub cache_count: usize,
186    pub graphql_count: usize,
187    pub grpc_count: usize,
188    pub rabbit_count: usize,
189    pub job_count: usize,
190}
191
192impl From<&RequestSnapshot> for RequestMeta {
193    fn from(s: &RequestSnapshot) -> Self {
194        Self {
195            id: s.id.clone(),
196            request_id: s.request_id.clone(),
197            method: s.method.clone(),
198            path: s.path.clone(),
199            status: s.status,
200            duration_ms: s.duration_ms,
201            at_ms: s.at_ms,
202            sql_count: s.queries.len(),
203            log_errors: s
204                .logs
205                .iter()
206                .filter(|l| l.level.eq_ignore_ascii_case("ERROR") || l.level == "ERROR")
207                .count(),
208            http_count: s.http.len(),
209            mail_count: s.mail.len(),
210            cache_count: s.cache.len(),
211            graphql_count: s.graphql.len(),
212            grpc_count: s.grpc.len(),
213            rabbit_count: s.rabbit.len(),
214            job_count: s.jobs.len(),
215        }
216    }
217}
218
219#[derive(Default)]
220struct BagInner {
221    logs: Vec<LogLine>,
222    queries: Vec<QueryLine>,
223    http: Vec<HttpLine>,
224    mail: Vec<MailLine>,
225    jobs: Vec<JobLine>,
226    cache: Vec<CacheLine>,
227    graphql: Vec<GraphqlLine>,
228    grpc: Vec<GrpcLine>,
229    rabbit: Vec<RabbitLine>,
230    auth: AuthSnap,
231    route: RouteSnap,
232    locale: Option<String>,
233    csrf: Option<bool>,
234    rate_limit: Option<RateLimitSnap>,
235    encoding: Option<String>,
236}
237
238/// Per-request collection bag (stored on Request extensions).
239#[derive(Clone)]
240pub struct DevToolsBag {
241    pub id: String,
242    pub request_id: String,
243    pub method: String,
244    pub path: String,
245    pub started: Instant,
246    inner: Arc<Mutex<BagInner>>,
247}
248
249impl DevToolsBag {
250    pub fn new(id: String, request_id: String, method: String, path: String) -> Self {
251        Self {
252            id,
253            request_id,
254            method,
255            path: path.clone(),
256            started: Instant::now(),
257            inner: Arc::new(Mutex::new(BagInner {
258                route: RouteSnap {
259                    path,
260                    ..Default::default()
261                },
262                ..Default::default()
263            })),
264        }
265    }
266
267    pub fn push_log(&self, line: LogLine) {
268        let mut g = self.inner.lock().unwrap();
269        if g.logs.len() < 200 {
270            g.logs.push(line);
271        }
272    }
273
274    pub fn push_query(&self, q: QueryLine) {
275        let mut g = self.inner.lock().unwrap();
276        if g.queries.len() < 200 {
277            g.queries.push(q);
278        }
279    }
280
281    pub fn push_http(&self, h: HttpLine) {
282        let mut g = self.inner.lock().unwrap();
283        if g.http.len() < 100 {
284            g.http.push(h);
285        }
286    }
287
288    pub fn push_mail(&self, m: MailLine) {
289        let mut g = self.inner.lock().unwrap();
290        if g.mail.len() < 50 {
291            g.mail.push(m);
292        }
293    }
294
295    pub fn push_job(&self, j: JobLine) {
296        let mut g = self.inner.lock().unwrap();
297        if g.jobs.len() < 50 {
298            g.jobs.push(j);
299        }
300    }
301
302    pub fn push_cache(&self, c: CacheLine) {
303        let mut g = self.inner.lock().unwrap();
304        if g.cache.len() < 200 {
305            g.cache.push(c);
306        }
307    }
308
309    pub fn push_graphql(&self, gql: GraphqlLine) {
310        let mut g = self.inner.lock().unwrap();
311        if g.graphql.len() < 50 {
312            g.graphql.push(gql);
313        }
314    }
315
316    pub fn push_grpc(&self, line: GrpcLine) {
317        let mut g = self.inner.lock().unwrap();
318        if g.grpc.len() < 50 {
319            g.grpc.push(line);
320        }
321    }
322
323    pub fn push_rabbit(&self, line: RabbitLine) {
324        let mut g = self.inner.lock().unwrap();
325        if g.rabbit.len() < 50 {
326            g.rabbit.push(line);
327        }
328    }
329
330    pub fn set_auth(&self, auth: AuthSnap) {
331        self.inner.lock().unwrap().auth = auth;
332    }
333
334    pub fn set_route(&self, route: RouteSnap) {
335        self.inner.lock().unwrap().route = route;
336    }
337
338    pub fn set_locale(&self, locale: Option<String>) {
339        self.inner.lock().unwrap().locale = locale;
340    }
341
342    pub fn set_csrf(&self, present: Option<bool>) {
343        self.inner.lock().unwrap().csrf = present;
344    }
345
346    pub fn set_rate_limit(&self, rl: Option<RateLimitSnap>) {
347        self.inner.lock().unwrap().rate_limit = rl;
348    }
349
350    pub fn set_encoding(&self, encoding: Option<String>) {
351        self.inner.lock().unwrap().encoding = encoding;
352    }
353
354    pub fn finish(self, status: u16) -> RequestSnapshot {
355        let duration_ms = self.started.elapsed().as_secs_f64() * 1000.0;
356        let at_ms = SystemTime::now()
357            .duration_since(UNIX_EPOCH)
358            .unwrap_or(Duration::ZERO)
359            .as_millis() as u64;
360        let inner = self.inner.lock().unwrap();
361        RequestSnapshot {
362            id: self.id,
363            request_id: self.request_id,
364            method: self.method,
365            path: self.path,
366            status,
367            duration_ms,
368            at_ms,
369            logs: inner.logs.clone(),
370            queries: inner.queries.clone(),
371            http: inner.http.clone(),
372            mail: inner.mail.clone(),
373            jobs: inner.jobs.clone(),
374            cache: inner.cache.clone(),
375            graphql: inner.graphql.clone(),
376            grpc: inner.grpc.clone(),
377            rabbit: inner.rabbit.clone(),
378            auth: inner.auth.clone(),
379            route: inner.route.clone(),
380            locale: inner.locale.clone(),
381            csrf: inner.csrf,
382            rate_limit: inner.rate_limit.clone(),
383            encoding: inner.encoding.clone(),
384        }
385    }
386}
387
388pub fn now_ms() -> u64 {
389    SystemTime::now()
390        .duration_since(UNIX_EPOCH)
391        .unwrap_or(Duration::ZERO)
392        .as_millis() as u64
393}
394
395/// Truncate keys for safe display / logs.
396pub fn truncate_key(key: &str, max: usize) -> String {
397    if key.len() <= max {
398        key.to_string()
399    } else {
400        format!("{}…", &key[..max.saturating_sub(1)])
401    }
402}