1use crate::collector::{
4 now_ms, truncate_key, CacheLine, GraphqlLine, GrpcLine, HttpLine, JobLine, LogLine, QueryLine,
5 RabbitLine,
6};
7#[cfg(feature = "console")]
8use crate::console::{DevToolsConsole, DEFAULT_BODY_LIMIT};
9use crate::hub::DevToolsHub;
10use crate::middleware;
11use crate::redact::redact_sql_bindings;
12use crate::routes;
13use serde_json::json;
14use sova_core::{
15 add_log_event_hook, App, AppDispatch, DevToolsConfigRegistry, LogRecord, Plugin, PluginMeta,
16};
17use std::sync::Arc;
18
19pub struct DevTools {
21 enabled: Option<bool>,
22 request_cap: usize,
23 log_cap: usize,
24 #[cfg(feature = "console")]
25 console: Option<bool>,
26 #[cfg(feature = "console")]
27 allow_dangerous: bool,
28 #[cfg(feature = "console")]
29 console_external: bool,
30}
31
32impl Default for DevTools {
33 fn default() -> Self {
34 Self::new()
35 }
36}
37
38impl DevTools {
39 pub fn new() -> Self {
40 Self {
41 enabled: None,
42 request_cap: 100,
43 log_cap: 500,
44 #[cfg(feature = "console")]
45 console: None,
46 #[cfg(feature = "console")]
47 allow_dangerous: false,
48 #[cfg(feature = "console")]
49 console_external: false,
50 }
51 }
52
53 pub fn enabled(mut self, on: bool) -> Self {
57 self.enabled = Some(on);
58 self
59 }
60
61 pub fn request_cap(mut self, n: usize) -> Self {
62 self.request_cap = n;
63 self
64 }
65
66 pub fn log_cap(mut self, n: usize) -> Self {
67 self.log_cap = n;
68 self
69 }
70
71 #[cfg(feature = "console")]
73 pub fn console(mut self, on: bool) -> Self {
74 self.console = Some(on);
75 self
76 }
77
78 #[cfg(feature = "console")]
80 pub fn allow_dangerous(mut self, on: bool) -> Self {
81 self.allow_dangerous = on;
82 self
83 }
84
85 #[cfg(feature = "console")]
87 pub fn console_external(mut self, on: bool) -> Self {
88 self.console_external = on;
89 self
90 }
91}
92
93fn env_devtools_flag() -> Option<bool> {
94 let Ok(v) = std::env::var("SOVA_DEVTOOLS") else {
95 return None;
96 };
97 let v = v.to_ascii_lowercase();
98 if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
99 return Some(true);
100 }
101 if matches!(v.as_str(), "0" | "false" | "no" | "off") {
102 return Some(false);
103 }
104 None
105}
106
107fn is_production_profile(profile: &str) -> bool {
108 matches!(
109 profile.to_ascii_lowercase().as_str(),
110 "production" | "release" | "prod"
111 )
112}
113
114fn current_profile(app: &App) -> String {
115 if let Some(p) = app
116 .config_doc()
117 .map(|d| d.profile.clone())
118 .filter(|p| !p.is_empty())
119 {
120 return p;
121 }
122 std::env::var("SOVA_PROFILE")
123 .or_else(|_| std::env::var("SOVA_ENV"))
124 .unwrap_or_else(|_| {
125 if cfg!(debug_assertions) {
126 "development".into()
127 } else {
128 "production".into()
129 }
130 })
131}
132
133fn resolve_enabled(app: &App, explicit: Option<bool>) -> bool {
142 match env_devtools_flag() {
143 Some(false) => return false,
144 Some(true) => return true,
145 None => {}
146 }
147
148 if !cfg!(debug_assertions) {
149 return false;
150 }
151
152 if is_production_profile(¤t_profile(app)) {
153 return explicit == Some(true);
154 }
155
156 if let Some(v) = explicit {
157 return v;
158 }
159 if let Some(section) = app.config_doc().and_then(|d| d.section("devtools")) {
160 if let Some(v) = section.get("enabled").and_then(|v| v.as_bool()) {
161 return v;
162 }
163 }
164 true
165}
166
167impl Plugin for DevTools {
168 fn id(&self) -> &'static str {
169 "devtools"
170 }
171
172 fn meta(&self) -> PluginMeta {
173 PluginMeta::new("DevTools")
174 .description("In-app debug bar (HTML inject, SSE timeline, request snapshots)")
175 .version(env!("CARGO_PKG_VERSION"))
176 }
177
178 fn install(self, app: &mut App) {
179 if !resolve_enabled(app, self.enabled) {
180 tracing::debug!("devtools: disabled");
181 return;
182 }
183
184 let hub = DevToolsHub::new(self.request_cap, self.log_cap);
185
186 sova_core::logger_skip_path("/_devtools");
188
189 let profile = current_profile(app);
190 hub.set_config_info(Vec::new(), profile);
192
193 let reg = app
194 .try_state::<DevToolsConfigRegistry>()
195 .unwrap_or_else(|| {
196 app.state(DevToolsConfigRegistry::default());
197 app.try_state::<DevToolsConfigRegistry>()
198 .expect("DevToolsConfigRegistry")
199 });
200 hub.set_config_registry((*reg).clone());
201
202 wire_log_hook(hub.clone());
203
204 crate::hub::wire_event_bus(app, hub.clone());
205 crate::hub::spawn_memory_sampler(hub.clone(), std::time::Duration::from_secs(2));
206
207 app.state(hub.clone());
208 middleware::install(app, hub.clone());
209
210 #[cfg(feature = "console")]
211 {
212 let console_on = self.console.unwrap_or(true);
213 if app.try_state::<AppDispatch>().is_none() {
214 app.state(AppDispatch::default());
215 }
216 let console = DevToolsConsole::new(
217 console_on,
218 self.allow_dangerous,
219 self.console_external,
220 DEFAULT_BODY_LIMIT,
221 );
222 app.state(console.clone());
223 crate::actions::mount(app, hub.clone(), console);
224 }
225
226 routes::mount(app, hub);
227
228 tracing::info!("devtools: enabled (bar on text/html, SSE /_devtools/events)");
229 }
230}
231
232fn path_is_devtools(path: &str) -> bool {
233 path == "/_devtools" || path.starts_with("/_devtools/")
234}
235
236fn wire_log_hook(hub: DevToolsHub) {
237 let hub = Arc::new(hub);
238 add_log_event_hook(Arc::new(move |rec: LogRecord| {
239 if let Some(path) = field(&rec, "path") {
240 if path_is_devtools(path.trim_matches('"')) {
241 return;
242 }
243 }
244
245 let request_id = field(&rec, "request_id").or_else(sova_core::current_request_id);
246
247 let target = rec.target.as_str();
248
249 if target.starts_with("sova.store") || target.starts_with("sova.redis") {
250 let op = field(&rec, "op")
251 .or_else(|| field(&rec, "cmd"))
252 .unwrap_or_else(|| "op".into());
253 let key = field(&rec, "key")
254 .or_else(|| field(&rec, "channel"))
255 .or_else(|| field(&rec, "queue"))
256 .unwrap_or_default();
257 let hit = field(&rec, "hit").and_then(|s| match s.as_str() {
258 "true" | "1" => Some(true),
259 "false" | "0" => Some(false),
260 _ => None,
261 });
262 let bytes = field(&rec, "bytes").and_then(|s| s.parse().ok());
263 let duration_ms = field(&rec, "duration_ms").and_then(|s| s.parse().ok());
264 let ok = field(&rec, "ok").and_then(|s| match s.as_str() {
265 "true" | "1" => Some(true),
266 "false" | "0" => Some(false),
267 _ => None,
268 });
269 let backend = if target.starts_with("sova.redis") {
270 "redis".into()
271 } else {
272 field(&rec, "backend").unwrap_or_else(|| "kv".into())
273 };
274 let line = CacheLine {
275 op,
276 key: truncate_key(&key, 120),
277 hit,
278 bytes,
279 duration_ms,
280 backend,
281 ok,
282 };
283 open_bags::with_open(request_id.as_deref(), |bag| bag.push_cache(line));
284 let msg = format!(
286 "[{}] {} {}",
287 target.trim_start_matches("sova."),
288 field(&rec, "op")
289 .or_else(|| field(&rec, "cmd"))
290 .unwrap_or_default(),
291 truncate_key(&key, 80)
292 );
293 let log = LogLine {
294 level: rec.level.clone(),
295 target: rec.target.clone(),
296 message: msg,
297 request_id: request_id.clone(),
298 at_ms: now_ms(),
299 };
300 open_bags::with_open(request_id.as_deref(), |bag| bag.push_log(log.clone()));
301 hub.push_log(log);
302 return;
303 }
304
305 if target.starts_with("sova.graphql") {
306 if target == "sova.graphql.ws" {
307 let event = field(&rec, "event").unwrap_or_else(|| "ws".into());
308 hub.emit(
309 format!("graphql.ws.{event}"),
310 json!({
311 "protocol": field(&rec, "protocol"),
312 "path": field(&rec, "path"),
313 }),
314 );
315 let log = LogLine {
316 level: rec.level.clone(),
317 target: rec.target.clone(),
318 message: format!("[graphql.ws] {event}"),
319 request_id: request_id.clone(),
320 at_ms: now_ms(),
321 };
322 open_bags::with_open(request_id.as_deref(), |bag| bag.push_log(log.clone()));
323 hub.push_log(log);
324 return;
325 }
326
327 let operation = field(&rec, "operation").unwrap_or_else(|| "(anonymous)".into());
328 let kind = field(&rec, "kind").unwrap_or_else(|| "query".into());
329 let duration_ms = field(&rec, "duration_ms")
330 .and_then(|s| s.parse().ok())
331 .unwrap_or(0.0);
332 let errors = field(&rec, "errors")
333 .and_then(|s| s.parse().ok())
334 .unwrap_or(0);
335 let auth = field(&rec, "auth").and_then(|s| match s.as_str() {
336 "true" | "1" => Some(true),
337 "false" | "0" => Some(false),
338 _ => None,
339 });
340 let line = GraphqlLine {
341 operation: operation.clone(),
342 kind: kind.clone(),
343 duration_ms,
344 errors,
345 auth,
346 };
347 attach_graphql_to_open(request_id.as_deref(), line);
348 let log = LogLine {
349 level: rec.level.clone(),
350 target: rec.target.clone(),
351 message: format!("[graphql] {kind} {operation} ({duration_ms}ms)"),
352 request_id: request_id.clone(),
353 at_ms: now_ms(),
354 };
355 open_bags::with_open(request_id.as_deref(), |bag| bag.push_log(log.clone()));
356 hub.push_log(log);
357 return;
358 }
359
360 if target.starts_with("sova.grpc") {
361 let method = field(&rec, "method").unwrap_or_default();
362 let base = field(&rec, "base").unwrap_or_default();
363 let direction = field(&rec, "direction").unwrap_or_else(|| "client".into());
364 let duration_ms = field(&rec, "duration_ms")
365 .and_then(|s| s.parse().ok())
366 .unwrap_or(0.0);
367 let ok = field(&rec, "ok")
368 .and_then(|s| match s.as_str() {
369 "true" | "1" => Some(true),
370 "false" | "0" => Some(false),
371 _ => None,
372 })
373 .unwrap_or(true);
374 let status = field(&rec, "status").and_then(|s| s.parse().ok());
375 let error = field(&rec, "error");
376 let bytes_in = field(&rec, "bytes_in").and_then(|s| s.parse().ok());
377 let bytes_out = field(&rec, "bytes_out").and_then(|s| s.parse().ok());
378 let line = GrpcLine {
379 method: method.clone(),
380 base: base.clone(),
381 direction: direction.clone(),
382 duration_ms,
383 ok,
384 status,
385 error: error.clone(),
386 bytes_in,
387 bytes_out,
388 };
389 attach_grpc_to_open(request_id.as_deref(), line);
390 let status_label = if ok { "ok" } else { "err" };
391 let log = LogLine {
392 level: rec.level.clone(),
393 target: rec.target.clone(),
394 message: format!("[grpc] {direction} {method} ({duration_ms}ms) {status_label}"),
395 request_id: request_id.clone(),
396 at_ms: now_ms(),
397 };
398 open_bags::with_open(request_id.as_deref(), |bag| bag.push_log(log.clone()));
399 hub.push_log(log);
400 return;
401 }
402
403 if target.starts_with("sova.rabbit") {
404 let op = field(&rec, "op").unwrap_or_else(|| "op".into());
405 let exchange = field(&rec, "exchange");
406 let routing_key = field(&rec, "routing_key");
407 let queue = field(&rec, "queue");
408 let bytes = field(&rec, "bytes").and_then(|s| s.parse().ok());
409 let duration_ms = field(&rec, "duration_ms")
410 .and_then(|s| s.parse().ok())
411 .unwrap_or(0.0);
412 let ok = field(&rec, "ok")
413 .and_then(|s| match s.as_str() {
414 "true" | "1" => Some(true),
415 "false" | "0" => Some(false),
416 _ => None,
417 })
418 .unwrap_or(true);
419 let error = field(&rec, "error");
420 let line = RabbitLine {
421 op: op.clone(),
422 exchange: exchange.clone(),
423 routing_key: routing_key.clone(),
424 queue: queue.clone(),
425 bytes,
426 duration_ms,
427 ok,
428 error: error.clone(),
429 };
430 attach_rabbit_to_open(request_id.as_deref(), line);
431 let log = LogLine {
432 level: rec.level.clone(),
433 target: rec.target.clone(),
434 message: format!("[rabbit] {op} ({duration_ms}ms)"),
435 request_id: request_id.clone(),
436 at_ms: now_ms(),
437 };
438 open_bags::with_open(request_id.as_deref(), |bag| bag.push_log(log.clone()));
439 hub.push_log(log);
440 return;
441 }
442
443 if target.starts_with("sova.tasks") {
444 let name = field(&rec, "name").unwrap_or_else(|| "job".into());
445 let status = field(&rec, "status").unwrap_or_else(|| rec.message.clone());
446 let detail = field(&rec, "id")
447 .map(|id| {
448 let q = field(&rec, "queue").unwrap_or_default();
449 if q.is_empty() {
450 id
451 } else {
452 format!("queue={q} id={id}")
453 }
454 })
455 .or_else(|| field(&rec, "queue"));
456 let duration_ms = field(&rec, "duration_ms").and_then(|s| s.parse().ok());
457 let job = JobLine {
458 name,
459 status,
460 detail,
461 duration_ms,
462 };
463 open_bags::with_open(request_id.as_deref(), |bag| bag.push_job(job));
464 let log = LogLine {
465 level: rec.level.clone(),
466 target: rec.target.clone(),
467 message: format!("[tasks] {}", rec.message),
468 request_id: request_id.clone(),
469 at_ms: now_ms(),
470 };
471 open_bags::with_open(request_id.as_deref(), |bag| bag.push_log(log.clone()));
472 hub.push_log(log);
473 return;
474 }
475
476 let is_sql = target.starts_with("sqlx::query")
477 || target.starts_with("sova.db")
478 || target.contains("sea_orm")
479 || rec.message.contains("SELECT")
480 || rec.message.contains("INSERT")
481 || rec.message.contains("UPDATE")
482 || rec.message.contains("DELETE");
483
484 let is_http_client = target.contains("http.client")
485 || rec.message.contains("http.client")
486 || rec.message == "http.client done"
487 || rec.message == "http.client error";
488
489 if is_sql {
490 let sql = redact_sql_bindings(&rec.message);
491 let duration_ms = field(&rec, "elapsed")
492 .or_else(|| field(&rec, "duration_ms"))
493 .and_then(|v| v.trim_matches('"').parse::<f64>().ok());
494 let line = LogLine {
495 level: rec.level.clone(),
496 target: rec.target.clone(),
497 message: format!("[sql] {sql}"),
498 request_id: request_id.clone(),
499 at_ms: now_ms(),
500 };
501 open_bags::with_open(request_id.as_deref(), |bag| bag.push_log(line.clone()));
502 hub.push_log(line);
503 attach_query_to_open(
504 request_id.as_deref(),
505 QueryLine {
506 sql,
507 duration_ms,
508 rows: None,
509 },
510 );
511 return;
512 }
513
514 if is_http_client {
515 let method = field(&rec, "http.method")
516 .or_else(|| field(&rec, "method"))
517 .unwrap_or_else(|| "?".into());
518 let url = field(&rec, "http.url")
519 .or_else(|| field(&rec, "url"))
520 .or_else(|| field(&rec, "uri"))
521 .unwrap_or_else(|| rec.message.clone());
522 let status = field(&rec, "status").and_then(|s| s.parse().ok());
523 let duration_ms = field(&rec, "duration_ms").and_then(|s| s.parse().ok());
524 let error = field(&rec, "error");
525 attach_http_to_open(
526 request_id.as_deref(),
527 HttpLine {
528 method,
529 url,
530 status,
531 duration_ms,
532 error,
533 },
534 );
535 }
537
538 let message = if rec.message == "request" {
539 let method = field(&rec, "method").unwrap_or_else(|| "?".into());
540 let path = field(&rec, "path").unwrap_or_else(|| "?".into());
541 let status = field(&rec, "status").unwrap_or_else(|| "?".into());
542 let ms = field(&rec, "latency_ms").unwrap_or_else(|| "?".into());
543 format!("{method} {path} → {status} ({ms}ms)")
544 } else {
545 rec.message.clone()
546 };
547
548 let line = LogLine {
549 level: rec.level,
550 target: rec.target,
551 message,
552 request_id: request_id.clone(),
553 at_ms: now_ms(),
554 };
555 open_bags::with_open(request_id.as_deref(), |bag| bag.push_log(line.clone()));
556 hub.push_log(line);
557 }));
558}
559
560fn field(rec: &LogRecord, name: &str) -> Option<String> {
561 rec.fields
562 .iter()
563 .find(|(k, _)| k == name)
564 .map(|(_, v)| v.trim_matches('"').to_string())
565}
566
567pub(crate) mod open_bags {
569 use crate::collector::DevToolsBag;
570 use std::collections::HashMap;
571 use std::sync::{Mutex, OnceLock};
572
573 static MAP: OnceLock<Mutex<HashMap<String, DevToolsBag>>> = OnceLock::new();
574
575 fn map() -> &'static Mutex<HashMap<String, DevToolsBag>> {
576 MAP.get_or_init(|| Mutex::new(HashMap::new()))
577 }
578
579 pub fn insert(bag: &DevToolsBag) {
580 if bag.request_id == "-" {
581 return;
582 }
583 map()
584 .lock()
585 .unwrap()
586 .insert(bag.request_id.clone(), bag.clone());
587 }
588
589 pub fn remove(request_id: &str) {
590 map().lock().unwrap().remove(request_id);
591 }
592
593 pub fn with_open(request_id: Option<&str>, f: impl FnOnce(&DevToolsBag)) {
594 let Some(id) = request_id else {
595 return;
596 };
597 let g = map().lock().unwrap();
598 if let Some(bag) = g.get(id) {
599 f(bag);
600 }
601 }
602}
603
604fn attach_query_to_open(request_id: Option<&str>, q: QueryLine) {
605 open_bags::with_open(request_id, |bag| bag.push_query(q));
606}
607
608fn attach_http_to_open(request_id: Option<&str>, h: HttpLine) {
609 open_bags::with_open(request_id, |bag| bag.push_http(h));
610}
611
612fn attach_graphql_to_open(request_id: Option<&str>, g: GraphqlLine) {
613 open_bags::with_open(request_id, |bag| bag.push_graphql(g));
614}
615
616fn attach_grpc_to_open(request_id: Option<&str>, g: GrpcLine) {
617 open_bags::with_open(request_id, |bag| bag.push_grpc(g));
618}
619
620fn attach_rabbit_to_open(request_id: Option<&str>, r: RabbitLine) {
621 open_bags::with_open(request_id, |bag| bag.push_rabbit(r));
622}