Skip to main content

sova_devtools/
hub.rs

1//! Process-wide ring buffer + SSE fan-out.
2
3use crate::collector::{LogLine, RequestMeta, RequestSnapshot, now_ms};
4use serde::Serialize;
5use serde_json::{json, Value};
6use sova_sse::{SseChannel, SseEvent};
7use std::collections::{HashMap, VecDeque};
8use std::sync::{
9    atomic::{AtomicU64, Ordering},
10    Arc, Mutex,
11};
12use std::time::Duration;
13
14static SEQ: AtomicU64 = AtomicU64::new(1);
15
16pub fn next_id() -> String {
17    format!("dt-{}", SEQ.fetch_add(1, Ordering::Relaxed))
18}
19
20#[derive(Clone, Debug, Serialize)]
21pub struct CustomEvent {
22    pub id: String,
23    pub kind: String,
24    pub payload: Value,
25    pub ts_ms: u64,
26}
27
28#[derive(Clone, Debug, Serialize)]
29pub struct MemorySample {
30    pub ts_ms: u64,
31    pub rss_bytes: Option<u64>,
32    pub rss_peak_bytes: Option<u64>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub available_bytes: Option<u64>,
35}
36
37#[derive(Clone, Debug, Serialize)]
38pub struct MemorySummary {
39    pub samples: Vec<MemorySample>,
40    pub current: Option<u64>,
41    pub peak: Option<u64>,
42    pub min: Option<u64>,
43}
44
45struct HubInner {
46    requests: VecDeque<RequestSnapshot>,
47    by_id: HashMap<String, RequestSnapshot>,
48    logs: VecDeque<LogLine>,
49    custom: VecDeque<CustomEvent>,
50    memory: VecDeque<MemorySample>,
51    rss_peak: Option<u64>,
52    plugins: Vec<String>,
53    profile: String,
54    event_seq: u64,
55    custom_cap: usize,
56    memory_cap: usize,
57}
58
59/// Shared DevTools state installed on the app.
60#[derive(Clone)]
61pub struct DevToolsHub {
62    inner: Arc<Mutex<HubInner>>,
63    pub channel: SseChannel,
64    request_cap: usize,
65    log_cap: usize,
66}
67
68impl DevToolsHub {
69    pub fn new(request_cap: usize, log_cap: usize) -> Self {
70        let channel = SseChannel::new(256).history_cap(100);
71        Self {
72            inner: Arc::new(Mutex::new(HubInner {
73                requests: VecDeque::new(),
74                by_id: HashMap::new(),
75                logs: VecDeque::new(),
76                custom: VecDeque::new(),
77                memory: VecDeque::new(),
78                rss_peak: None,
79                plugins: Vec::new(),
80                profile: String::new(),
81                event_seq: 0,
82                custom_cap: 100,
83                memory_cap: 120,
84            })),
85            channel,
86            request_cap: request_cap.max(10),
87            log_cap: log_cap.max(50),
88        }
89    }
90
91    pub fn set_config_info(&self, plugins: Vec<String>, profile: String) {
92        let mut g = self.inner.lock().unwrap();
93        g.plugins = plugins;
94        g.profile = profile;
95    }
96
97    fn next_eid(g: &mut HubInner) -> String {
98        g.event_seq += 1;
99        g.event_seq.to_string()
100    }
101
102    pub fn push_snapshot(&self, snap: RequestSnapshot) {
103        let meta = RequestMeta::from(&snap);
104        let mut g = self.inner.lock().unwrap();
105        let eid = Self::next_eid(&mut g);
106        g.by_id.insert(snap.id.clone(), snap.clone());
107        g.requests.push_back(snap);
108        while g.requests.len() > self.request_cap {
109            if let Some(old) = g.requests.pop_front() {
110                g.by_id.remove(&old.id);
111            }
112        }
113        drop(g);
114        let data = serde_json::to_string(&json!({
115            "type": "request.finished",
116            "meta": meta,
117        }))
118        .unwrap_or_else(|_| "{}".into());
119        self.channel.publish(
120            SseEvent::data(data)
121                .id(eid)
122                .event("request.finished"),
123        );
124    }
125
126    pub fn push_log(&self, line: LogLine) {
127        let mut g = self.inner.lock().unwrap();
128        let eid = Self::next_eid(&mut g);
129        g.logs.push_back(line.clone());
130        while g.logs.len() > self.log_cap {
131            g.logs.pop_front();
132        }
133        drop(g);
134        let data = serde_json::to_string(&json!({
135            "type": "log.line",
136            "line": line,
137        }))
138        .unwrap_or_else(|_| "{}".into());
139        self.channel
140            .publish(SseEvent::data(data).id(eid).event("log.line"));
141    }
142
143    /// Emit a custom application/plugin event onto the DevTools SSE feed.
144    pub fn emit(&self, kind: impl Into<String>, payload: Value) {
145        let ev = CustomEvent {
146            id: next_id(),
147            kind: kind.into(),
148            payload,
149            ts_ms: now_ms(),
150        };
151        let mut g = self.inner.lock().unwrap();
152        let eid = Self::next_eid(&mut g);
153        let cap = g.custom_cap;
154        g.custom.push_back(ev.clone());
155        while g.custom.len() > cap {
156            g.custom.pop_front();
157        }
158        drop(g);
159        let data = serde_json::to_string(&json!({
160            "type": "custom",
161            "event": ev,
162        }))
163        .unwrap_or_else(|_| "{}".into());
164        self.channel
165            .publish(SseEvent::data(data).id(eid).event("custom"));
166    }
167
168    pub fn push_memory_sample(&self, rss_bytes: Option<u64>) {
169        let available_bytes = process_mem_available_bytes();
170        let mut g = self.inner.lock().unwrap();
171        if let Some(rss) = rss_bytes {
172            g.rss_peak = Some(match g.rss_peak {
173                Some(p) => p.max(rss),
174                None => rss,
175            });
176        }
177        let sample = MemorySample {
178            ts_ms: now_ms(),
179            rss_bytes,
180            rss_peak_bytes: g.rss_peak,
181            available_bytes,
182        };
183        let eid = Self::next_eid(&mut g);
184        let cap = g.memory_cap;
185        g.memory.push_back(sample.clone());
186        while g.memory.len() > cap {
187            g.memory.pop_front();
188        }
189        drop(g);
190        let data = serde_json::to_string(&json!({
191            "type": "memory.sample",
192            "sample": sample,
193        }))
194        .unwrap_or_else(|_| "{}".into());
195        self.channel
196            .publish(SseEvent::data(data).id(eid).event("memory.sample"));
197    }
198
199    pub fn get(&self, id: &str) -> Option<RequestSnapshot> {
200        self.inner.lock().unwrap().by_id.get(id).cloned()
201    }
202
203    pub fn list_meta(&self, limit: usize) -> Vec<RequestMeta> {
204        let g = self.inner.lock().unwrap();
205        g.requests
206            .iter()
207            .rev()
208            .take(limit)
209            .map(RequestMeta::from)
210            .collect()
211    }
212
213    pub fn recent_logs(&self, limit: usize) -> Vec<LogLine> {
214        let g = self.inner.lock().unwrap();
215        g.logs.iter().rev().take(limit).cloned().collect()
216    }
217
218    pub fn recent_custom(&self, limit: usize) -> Vec<CustomEvent> {
219        let g = self.inner.lock().unwrap();
220        g.custom.iter().rev().take(limit).cloned().collect()
221    }
222
223    pub fn recent_memory(&self, limit: usize) -> MemorySummary {
224        let g = self.inner.lock().unwrap();
225        let samples: Vec<MemorySample> = g.memory.iter().rev().take(limit).cloned().collect();
226        let mut current: Option<u64> = None;
227        let mut peak: Option<u64> = g.rss_peak;
228        let mut min: Option<u64> = None;
229        for s in &samples {
230            if let Some(rss) = s.rss_bytes {
231                if current.is_none() {
232                    current = Some(rss);
233                }
234                peak = Some(peak.map_or(rss, |p: u64| p.max(rss)));
235                min = Some(min.map_or(rss, |m: u64| m.min(rss)));
236            }
237        }
238        MemorySummary {
239            samples,
240            current,
241            peak,
242            min,
243        }
244    }
245
246    pub fn config_json(&self) -> serde_json::Value {
247        let g = self.inner.lock().unwrap();
248        json!({
249            "profile": g.profile,
250            "plugins": g.plugins,
251            "features": compile_features(),
252        })
253    }
254}
255
256/// Best-effort process RSS (Linux `/proc`, macOS `task_info`).
257pub fn process_rss_bytes() -> Option<u64> {
258    #[cfg(target_os = "linux")]
259    {
260        let s = std::fs::read_to_string("/proc/self/status").ok()?;
261        for line in s.lines() {
262            if let Some(rest) = line.strip_prefix("VmRSS:") {
263                let kb: u64 = rest.split_whitespace().next()?.parse().ok()?;
264                return Some(kb.saturating_mul(1024));
265            }
266        }
267        None
268    }
269    #[cfg(target_os = "macos")]
270    {
271        macos_rss_bytes()
272    }
273    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
274    {
275        None
276    }
277}
278
279fn process_mem_available_bytes() -> Option<u64> {
280    #[cfg(target_os = "linux")]
281    {
282        let s = std::fs::read_to_string("/proc/meminfo").ok()?;
283        for line in s.lines() {
284            if let Some(rest) = line.strip_prefix("MemAvailable:") {
285                let kb: u64 = rest.split_whitespace().next()?.parse().ok()?;
286                return Some(kb.saturating_mul(1024));
287            }
288        }
289        None
290    }
291    #[cfg(not(target_os = "linux"))]
292    {
293        None
294    }
295}
296
297#[cfg(target_os = "macos")]
298fn macos_rss_bytes() -> Option<u64> {
299    // MACH_TASK_BASIC_INFO → resident_size (bytes).
300    #[repr(C)]
301    struct TaskBasicInfo {
302        suspend_count: u32,
303        virtual_size: u64,
304        resident_size: u64,
305        user_time: [u32; 2],
306        system_time: [u32; 2],
307        policy: i32,
308    }
309    const TASK_BASIC_INFO: u32 = 5;
310    const TASK_BASIC_INFO_COUNT: u32 =
311        (std::mem::size_of::<TaskBasicInfo>() / std::mem::size_of::<u32>()) as u32;
312
313    extern "C" {
314        fn mach_task_self() -> u32;
315        fn task_info(
316            target_task: u32,
317            flavor: u32,
318            task_info_out: *mut TaskBasicInfo,
319            task_info_count: *mut u32,
320        ) -> i32;
321    }
322
323    let mut info = unsafe { std::mem::zeroed::<TaskBasicInfo>() };
324    let mut count = TASK_BASIC_INFO_COUNT;
325    let kr = unsafe {
326        task_info(
327            mach_task_self(),
328            TASK_BASIC_INFO,
329            &mut info,
330            &mut count,
331        )
332    };
333    if kr == 0 {
334        Some(info.resident_size)
335    } else {
336        None
337    }
338}
339
340/// Background RSS sampler → SSE `memory.sample`.
341pub fn spawn_memory_sampler(hub: DevToolsHub, interval: Duration) {
342    tokio::spawn(async move {
343        let mut tick = tokio::time::interval(interval);
344        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
345        loop {
346            tick.tick().await;
347            hub.push_memory_sample(process_rss_bytes());
348        }
349    });
350}
351
352/// Forward domain EventBus events into [`DevToolsHub::emit`].
353pub fn wire_event_bus(app: &mut sova_core::App, hub: DevToolsHub) {
354    let bus = app.events();
355
356    #[cfg(feature = "auth")]
357    {
358        let h = hub.clone();
359        bus.listen::<sova_auth::UserRegistered, _>(move |e| {
360            h.emit(
361                "auth.user_registered",
362                json!({ "user_id": e.user_id, "email": e.email }),
363            );
364        });
365        let h = hub.clone();
366        bus.listen::<sova_auth::UserLoggedIn, _>(move |e| {
367            h.emit(
368                "auth.user_logged_in",
369                json!({ "user_id": e.user_id, "email": e.email }),
370            );
371        });
372    }
373
374    #[cfg(feature = "mail")]
375    {
376        let h = hub.clone();
377        bus.listen::<sova_mail::MailSent, _>(move |e| {
378            h.emit(
379                "mail.sent",
380                json!({ "to": e.to, "subject": e.subject }),
381            );
382        });
383    }
384
385    #[cfg(feature = "fs")]
386    {
387        let h = hub.clone();
388        bus.listen::<sova_fs::FileWritten, _>(move |e| {
389            h.emit("fs.file_written", json!({ "path": e.path }));
390        });
391        let h = hub.clone();
392        bus.listen::<sova_fs::FileRemoved, _>(move |e| {
393            h.emit("fs.file_removed", json!({ "path": e.path }));
394        });
395        let h = hub.clone();
396        bus.listen::<sova_fs::DirCreated, _>(move |e| {
397            h.emit("fs.dir_created", json!({ "path": e.path }));
398        });
399    }
400
401    #[cfg(feature = "csrf")]
402    {
403        let h = hub.clone();
404        bus.listen::<sova_csrf::CsrfMismatch, _>(move |e| {
405            h.emit(
406                "csrf.mismatch",
407                json!({ "method": e.method, "path": e.path }),
408            );
409        });
410    }
411
412    #[cfg(feature = "rate-limit")]
413    {
414        let h = hub.clone();
415        bus.listen::<sova_rate_limit::RateLimitExceeded, _>(move |e| {
416            h.emit(
417                "rate_limit.exceeded",
418                json!({
419                    "key": e.key,
420                    "limit": e.limit,
421                    "retry_after": e.retry_after,
422                }),
423            );
424        });
425    }
426
427    #[cfg(feature = "session")]
428    {
429        let h = hub.clone();
430        bus.listen::<sova_session::SessionRegenerated, _>(move |e| {
431            h.emit(
432                "session.regenerated",
433                json!({ "had_user": e.had_user }),
434            );
435        });
436        let h = hub.clone();
437        bus.listen::<sova_session::SessionLogoutAll, _>(move |e| {
438            h.emit(
439                "session.logout_all",
440                json!({ "user_id": e.user_id, "count": e.count }),
441            );
442        });
443    }
444
445    #[cfg(feature = "tasks")]
446    {
447        let h = hub.clone();
448        bus.listen::<sova_tasks::TaskDispatched, _>(move |e| {
449            h.emit(
450                "tasks.dispatched",
451                json!({ "id": e.id, "name": e.name, "queue": e.queue }),
452            );
453        });
454        let h = hub.clone();
455        bus.listen::<sova_tasks::TaskFailed, _>(move |e| {
456            h.emit(
457                "tasks.failed",
458                json!({ "id": e.id, "name": e.name, "attempts": e.attempts }),
459            );
460        });
461    }
462
463    #[cfg(feature = "notifications")]
464    {
465        let h = hub.clone();
466        bus.listen::<sova_notifications::NotificationSent, _>(move |e| {
467            h.emit(
468                "notifications.sent",
469                json!({
470                    "channel": e.channel,
471                    "event": e.event,
472                    "recipients": e.recipients,
473                }),
474            );
475        });
476    }
477
478    #[cfg(feature = "passport")]
479    {
480        let h = hub.clone();
481        bus.listen::<sova_passport::ApiTokenRevoked, _>(move |e| {
482            h.emit(
483                "passport.api_token_revoked",
484                json!({ "user_id": e.user_id, "token_id": e.token_id }),
485            );
486        });
487    }
488
489    #[cfg(feature = "acme")]
490    {
491        let h = hub.clone();
492        bus.listen::<sova_acme::CertificateIssued, _>(move |e| {
493            h.emit(
494                "acme.certificate_issued",
495                json!({
496                    "domains": e.domains,
497                    "not_after_unix": e.not_after_unix,
498                }),
499            );
500        });
501        let h = hub.clone();
502        bus.listen::<sova_acme::CertificateRenewed, _>(move |e| {
503            h.emit(
504                "acme.certificate_renewed",
505                json!({
506                    "domains": e.domains,
507                    "not_after_unix": e.not_after_unix,
508                }),
509            );
510        });
511        let h = hub.clone();
512        bus.listen::<sova_acme::AcmeFailed, _>(move |e| {
513            h.emit(
514                "acme.failed",
515                json!({ "domains": e.domains, "error": e.error }),
516            );
517        });
518    }
519
520    let _ = bus;
521    let _ = hub;
522}
523
524fn compile_features() -> Vec<&'static str> {
525    #[allow(clippy::vec_init_then_push, unused_mut)]
526    {
527        let mut v = Vec::new();
528        #[cfg(feature = "session")]
529        v.push("session");
530        #[cfg(feature = "mail")]
531        v.push("mail");
532        #[cfg(feature = "http")]
533        v.push("http");
534        #[cfg(feature = "db")]
535        v.push("db");
536        #[cfg(feature = "tasks")]
537        v.push("tasks");
538        #[cfg(feature = "auth")]
539        v.push("auth");
540        #[cfg(feature = "i18n")]
541        v.push("i18n");
542        #[cfg(feature = "csrf")]
543        v.push("csrf");
544        #[cfg(feature = "passport")]
545        v.push("passport");
546        #[cfg(feature = "store")]
547        v.push("store");
548        #[cfg(feature = "redis")]
549        v.push("redis");
550        #[cfg(feature = "rate-limit")]
551        v.push("rate-limit");
552        #[cfg(feature = "notifications")]
553        v.push("notifications");
554        #[cfg(feature = "acme")]
555        v.push("acme");
556        #[cfg(feature = "fs")]
557        v.push("fs");
558        v
559    }
560}