Skip to main content

sova_devtools/
plugin.rs

1//! `DevTools` plugin entry.
2
3use 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
19/// In-app DevTools (HTML inject + SSE timeline). Disabled unless development / env.
20pub 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    /// Force enable/disable in **debug** builds (overrides toml).
54    ///
55    /// Ignored in release binaries — use `SOVA_DEVTOOLS=1` there if you must.
56    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    /// Enable control panel actions (HTTP console, Redis, …). Default on in debug.
72    #[cfg(feature = "console")]
73    pub fn console(mut self, on: bool) -> Self {
74        self.console = Some(on);
75        self
76    }
77
78    /// Allow dangerous Redis ops (FLUSH*, CONFIG, …).
79    #[cfg(feature = "console")]
80    pub fn allow_dangerous(mut self, on: bool) -> Self {
81        self.allow_dangerous = on;
82        self
83    }
84
85    /// Allow HTTP console to call external URLs (not implemented in v1).
86    #[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
133/// Enable only in development by default.
134///
135/// **Release binaries** (`cargo build --release`): always off unless
136/// `SOVA_DEVTOOLS=1` (ops escape hatch). Toml / `.enabled(true)` cannot turn
137/// it on in release — avoids shipping a debug surface by accident.
138///
139/// **Debug binaries** with production profile: off unless `SOVA_DEVTOOLS=1`
140/// or `.enabled(true)`.
141fn 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(&current_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        // Don't spam access logs / DevTools feed with the panel's own polling.
187        sova_core::logger_skip_path("/_devtools");
188
189        let profile = current_profile(app);
190        // Plugin list is not public on App — leave empty / fill from toml later.
191        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            // Also mirror as a short log line.
285            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            // Still fall through to logs for visibility.
536        }
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
567/// Registry of in-flight bags by request_id for tracing hooks.
568pub(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}