1use 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#[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 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
256pub 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 #[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
340pub 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
352pub 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}