Skip to main content

sova_devtools/
plugin.rs

1//! `DevTools` plugin entry.
2
3use crate::collector::{CacheLine, HttpLine, JobLine, LogLine, QueryLine, now_ms, truncate_key};
4use crate::hub::DevToolsHub;
5use crate::middleware;
6use crate::redact::redact_sql_bindings;
7use crate::routes;
8use sova_core::{add_log_event_hook, App, LogRecord, Plugin, PluginMeta};
9use std::sync::Arc;
10
11/// In-app DevTools (HTML inject + SSE timeline). Disabled unless development / env.
12pub struct DevTools {
13    enabled: Option<bool>,
14    request_cap: usize,
15    log_cap: usize,
16}
17
18impl Default for DevTools {
19    fn default() -> Self {
20        Self::new()
21    }
22}
23
24impl DevTools {
25    pub fn new() -> Self {
26        Self {
27            enabled: None,
28            request_cap: 100,
29            log_cap: 500,
30        }
31    }
32
33    /// Force enable/disable in **debug** builds (overrides toml).
34    ///
35    /// Ignored in release binaries — use `SOVA_DEVTOOLS=1` there if you must.
36    pub fn enabled(mut self, on: bool) -> Self {
37        self.enabled = Some(on);
38        self
39    }
40
41    pub fn request_cap(mut self, n: usize) -> Self {
42        self.request_cap = n;
43        self
44    }
45
46    pub fn log_cap(mut self, n: usize) -> Self {
47        self.log_cap = n;
48        self
49    }
50}
51
52fn env_devtools_flag() -> Option<bool> {
53    let Ok(v) = std::env::var("SOVA_DEVTOOLS") else {
54        return None;
55    };
56    let v = v.to_ascii_lowercase();
57    if matches!(v.as_str(), "1" | "true" | "yes" | "on") {
58        return Some(true);
59    }
60    if matches!(v.as_str(), "0" | "false" | "no" | "off") {
61        return Some(false);
62    }
63    None
64}
65
66fn is_production_profile(profile: &str) -> bool {
67    matches!(
68        profile.to_ascii_lowercase().as_str(),
69        "production" | "release" | "prod"
70    )
71}
72
73fn current_profile(app: &App) -> String {
74    if let Some(p) = app
75        .config_doc()
76        .map(|d| d.profile.clone())
77        .filter(|p| !p.is_empty())
78    {
79        return p;
80    }
81    std::env::var("SOVA_PROFILE")
82        .or_else(|_| std::env::var("SOVA_ENV"))
83        .unwrap_or_else(|_| {
84            if cfg!(debug_assertions) {
85                "development".into()
86            } else {
87                "production".into()
88            }
89        })
90}
91
92/// Enable only in development by default.
93///
94/// **Release binaries** (`cargo build --release`): always off unless
95/// `SOVA_DEVTOOLS=1` (ops escape hatch). Toml / `.enabled(true)` cannot turn
96/// it on in release — avoids shipping a debug surface by accident.
97///
98/// **Debug binaries** with production profile: off unless `SOVA_DEVTOOLS=1`
99/// or `.enabled(true)`.
100fn resolve_enabled(app: &App, explicit: Option<bool>) -> bool {
101    match env_devtools_flag() {
102        Some(false) => return false,
103        Some(true) => return true,
104        None => {}
105    }
106
107    if !cfg!(debug_assertions) {
108        return false;
109    }
110
111    if is_production_profile(&current_profile(app)) {
112        return explicit == Some(true);
113    }
114
115    if let Some(v) = explicit {
116        return v;
117    }
118    if let Some(section) = app.config_doc().and_then(|d| d.section("devtools")) {
119        if let Some(v) = section.get("enabled").and_then(|v| v.as_bool()) {
120            return v;
121        }
122    }
123    true
124}
125
126impl Plugin for DevTools {
127    fn id(&self) -> &'static str {
128        "devtools"
129    }
130
131    fn meta(&self) -> PluginMeta {
132        PluginMeta::new("DevTools")
133            .description("In-app debug bar (HTML inject, SSE timeline, request snapshots)")
134            .version(env!("CARGO_PKG_VERSION"))
135    }
136
137    fn install(self, app: &mut App) {
138        if !resolve_enabled(app, self.enabled) {
139            tracing::debug!("devtools: disabled");
140            return;
141        }
142
143        let hub = DevToolsHub::new(self.request_cap, self.log_cap);
144
145        // Don't spam access logs / DevTools feed with the panel's own polling.
146        sova_core::logger_skip_path("/_devtools");
147
148        let profile = current_profile(app);
149        // Plugin list is not public on App — leave empty / fill from toml later.
150        hub.set_config_info(Vec::new(), profile);
151
152        wire_log_hook(hub.clone());
153
154        app.state(hub.clone());
155        middleware::install(app, hub.clone());
156        routes::mount(app, hub);
157
158        // CSRF / security: document that /_devtools/* is GET-only debug surface.
159        tracing::info!("devtools: enabled (bar on text/html, SSE /_devtools/events)");
160    }
161}
162
163fn path_is_devtools(path: &str) -> bool {
164    path == "/_devtools" || path.starts_with("/_devtools/")
165}
166
167fn wire_log_hook(hub: DevToolsHub) {
168    let hub = Arc::new(hub);
169    add_log_event_hook(Arc::new(move |rec: LogRecord| {
170        if let Some(path) = field(&rec, "path") {
171            if path_is_devtools(path.trim_matches('"')) {
172                return;
173            }
174        }
175
176        let request_id = field(&rec, "request_id").or_else(|| {
177            sova_core::current_request_id()
178        });
179
180        let target = rec.target.as_str();
181
182        if target.starts_with("sova.store") || target.starts_with("sova.redis") {
183            let op = field(&rec, "op")
184                .or_else(|| field(&rec, "cmd"))
185                .unwrap_or_else(|| "op".into());
186            let key = field(&rec, "key")
187                .or_else(|| field(&rec, "channel"))
188                .or_else(|| field(&rec, "queue"))
189                .unwrap_or_default();
190            let hit = field(&rec, "hit").and_then(|s| match s.as_str() {
191                "true" | "1" => Some(true),
192                "false" | "0" => Some(false),
193                _ => None,
194            });
195            let bytes = field(&rec, "bytes").and_then(|s| s.parse().ok());
196            let duration_ms = field(&rec, "duration_ms").and_then(|s| s.parse().ok());
197            let ok = field(&rec, "ok").and_then(|s| match s.as_str() {
198                "true" | "1" => Some(true),
199                "false" | "0" => Some(false),
200                _ => None,
201            });
202            let backend = if target.starts_with("sova.redis") {
203                "redis".into()
204            } else {
205                field(&rec, "backend").unwrap_or_else(|| "kv".into())
206            };
207            let line = CacheLine {
208                op,
209                key: truncate_key(&key, 120),
210                hit,
211                bytes,
212                duration_ms,
213                backend,
214                ok,
215            };
216            open_bags::with_open(request_id.as_deref(), |bag| bag.push_cache(line));
217            // Also mirror as a short log line.
218            let msg = format!(
219                "[{}] {} {}",
220                target.trim_start_matches("sova."),
221                field(&rec, "op").or_else(|| field(&rec, "cmd")).unwrap_or_default(),
222                truncate_key(&key, 80)
223            );
224            let log = LogLine {
225                level: rec.level.clone(),
226                target: rec.target.clone(),
227                message: msg,
228                request_id: request_id.clone(),
229                at_ms: now_ms(),
230            };
231            open_bags::with_open(request_id.as_deref(), |bag| bag.push_log(log.clone()));
232            hub.push_log(log);
233            return;
234        }
235
236        if target.starts_with("sova.tasks") {
237            let name = field(&rec, "name").unwrap_or_else(|| "job".into());
238            let status = field(&rec, "status").unwrap_or_else(|| rec.message.clone());
239            let detail = field(&rec, "id")
240                .map(|id| {
241                    let q = field(&rec, "queue").unwrap_or_default();
242                    if q.is_empty() {
243                        id
244                    } else {
245                        format!("queue={q} id={id}")
246                    }
247                })
248                .or_else(|| field(&rec, "queue"));
249            let duration_ms = field(&rec, "duration_ms").and_then(|s| s.parse().ok());
250            let job = JobLine {
251                name,
252                status,
253                detail,
254                duration_ms,
255            };
256            open_bags::with_open(request_id.as_deref(), |bag| bag.push_job(job));
257            let log = LogLine {
258                level: rec.level.clone(),
259                target: rec.target.clone(),
260                message: format!("[tasks] {}", rec.message),
261                request_id: request_id.clone(),
262                at_ms: now_ms(),
263            };
264            open_bags::with_open(request_id.as_deref(), |bag| bag.push_log(log.clone()));
265            hub.push_log(log);
266            return;
267        }
268
269        let is_sql = target.starts_with("sqlx::query")
270            || target.contains("sea_orm")
271            || rec.message.contains("SELECT")
272            || rec.message.contains("INSERT")
273            || rec.message.contains("UPDATE")
274            || rec.message.contains("DELETE");
275
276        let is_http_client = target.contains("http.client")
277            || rec.message.contains("http.client")
278            || rec.message == "http.client done"
279            || rec.message == "http.client error";
280
281        if is_sql {
282            let sql = redact_sql_bindings(&rec.message);
283            let duration_ms = field(&rec, "elapsed")
284                .or_else(|| field(&rec, "duration_ms"))
285                .and_then(|v| v.trim_matches('"').parse::<f64>().ok());
286            let line = LogLine {
287                level: rec.level.clone(),
288                target: rec.target.clone(),
289                message: format!("[sql] {sql}"),
290                request_id: request_id.clone(),
291                at_ms: now_ms(),
292            };
293            open_bags::with_open(request_id.as_deref(), |bag| bag.push_log(line.clone()));
294            hub.push_log(line);
295            attach_query_to_open(
296                request_id.as_deref(),
297                QueryLine {
298                    sql,
299                    duration_ms,
300                    rows: None,
301                },
302            );
303            return;
304        }
305
306        if is_http_client {
307            let method = field(&rec, "http.method")
308                .or_else(|| field(&rec, "method"))
309                .unwrap_or_else(|| "?".into());
310            let url = field(&rec, "http.url")
311                .or_else(|| field(&rec, "url"))
312                .or_else(|| field(&rec, "uri"))
313                .unwrap_or_else(|| rec.message.clone());
314            let status = field(&rec, "status").and_then(|s| s.parse().ok());
315            let duration_ms = field(&rec, "duration_ms").and_then(|s| s.parse().ok());
316            let error = field(&rec, "error");
317            attach_http_to_open(
318                request_id.as_deref(),
319                HttpLine {
320                    method,
321                    url,
322                    status,
323                    duration_ms,
324                    error,
325                },
326            );
327            // Still fall through to logs for visibility.
328        }
329
330        let message = if rec.message == "request" {
331            let method = field(&rec, "method").unwrap_or_else(|| "?".into());
332            let path = field(&rec, "path").unwrap_or_else(|| "?".into());
333            let status = field(&rec, "status").unwrap_or_else(|| "?".into());
334            let ms = field(&rec, "latency_ms").unwrap_or_else(|| "?".into());
335            format!("{method} {path} → {status} ({ms}ms)")
336        } else {
337            rec.message.clone()
338        };
339
340        let line = LogLine {
341            level: rec.level,
342            target: rec.target,
343            message,
344            request_id: request_id.clone(),
345            at_ms: now_ms(),
346        };
347        open_bags::with_open(request_id.as_deref(), |bag| bag.push_log(line.clone()));
348        hub.push_log(line);
349    }));
350}
351
352fn field(rec: &LogRecord, name: &str) -> Option<String> {
353    rec.fields
354        .iter()
355        .find(|(k, _)| k == name)
356        .map(|(_, v)| v.trim_matches('"').to_string())
357}
358
359/// Registry of in-flight bags by request_id for tracing hooks.
360pub(crate) mod open_bags {
361    use crate::collector::DevToolsBag;
362    use std::collections::HashMap;
363    use std::sync::{Mutex, OnceLock};
364
365    static MAP: OnceLock<Mutex<HashMap<String, DevToolsBag>>> = OnceLock::new();
366
367    fn map() -> &'static Mutex<HashMap<String, DevToolsBag>> {
368        MAP.get_or_init(|| Mutex::new(HashMap::new()))
369    }
370
371    pub fn insert(bag: &DevToolsBag) {
372        if bag.request_id == "-" {
373            return;
374        }
375        map()
376            .lock()
377            .unwrap()
378            .insert(bag.request_id.clone(), bag.clone());
379    }
380
381    pub fn remove(request_id: &str) {
382        map().lock().unwrap().remove(request_id);
383    }
384
385    pub fn with_open(request_id: Option<&str>, f: impl FnOnce(&DevToolsBag)) {
386        let Some(id) = request_id else {
387            return;
388        };
389        let g = map().lock().unwrap();
390        if let Some(bag) = g.get(id) {
391            f(bag);
392        }
393    }
394}
395
396fn attach_query_to_open(request_id: Option<&str>, q: QueryLine) {
397    open_bags::with_open(request_id, |bag| bag.push_query(q));
398}
399
400fn attach_http_to_open(request_id: Option<&str>, h: HttpLine) {
401    open_bags::with_open(request_id, |bag| bag.push_http(h));
402}