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        crate::hub::wire_event_bus(app, hub.clone());
155        crate::hub::spawn_memory_sampler(hub.clone(), std::time::Duration::from_secs(2));
156
157        app.state(hub.clone());
158        middleware::install(app, hub.clone());
159        routes::mount(app, hub);
160
161        // CSRF / security: document that /_devtools/* is GET-only debug surface.
162        tracing::info!("devtools: enabled (bar on text/html, SSE /_devtools/events)");
163    }
164}
165
166fn path_is_devtools(path: &str) -> bool {
167    path == "/_devtools" || path.starts_with("/_devtools/")
168}
169
170fn wire_log_hook(hub: DevToolsHub) {
171    let hub = Arc::new(hub);
172    add_log_event_hook(Arc::new(move |rec: LogRecord| {
173        if let Some(path) = field(&rec, "path") {
174            if path_is_devtools(path.trim_matches('"')) {
175                return;
176            }
177        }
178
179        let request_id = field(&rec, "request_id").or_else(|| {
180            sova_core::current_request_id()
181        });
182
183        let target = rec.target.as_str();
184
185        if target.starts_with("sova.store") || target.starts_with("sova.redis") {
186            let op = field(&rec, "op")
187                .or_else(|| field(&rec, "cmd"))
188                .unwrap_or_else(|| "op".into());
189            let key = field(&rec, "key")
190                .or_else(|| field(&rec, "channel"))
191                .or_else(|| field(&rec, "queue"))
192                .unwrap_or_default();
193            let hit = field(&rec, "hit").and_then(|s| match s.as_str() {
194                "true" | "1" => Some(true),
195                "false" | "0" => Some(false),
196                _ => None,
197            });
198            let bytes = field(&rec, "bytes").and_then(|s| s.parse().ok());
199            let duration_ms = field(&rec, "duration_ms").and_then(|s| s.parse().ok());
200            let ok = field(&rec, "ok").and_then(|s| match s.as_str() {
201                "true" | "1" => Some(true),
202                "false" | "0" => Some(false),
203                _ => None,
204            });
205            let backend = if target.starts_with("sova.redis") {
206                "redis".into()
207            } else {
208                field(&rec, "backend").unwrap_or_else(|| "kv".into())
209            };
210            let line = CacheLine {
211                op,
212                key: truncate_key(&key, 120),
213                hit,
214                bytes,
215                duration_ms,
216                backend,
217                ok,
218            };
219            open_bags::with_open(request_id.as_deref(), |bag| bag.push_cache(line));
220            // Also mirror as a short log line.
221            let msg = format!(
222                "[{}] {} {}",
223                target.trim_start_matches("sova."),
224                field(&rec, "op").or_else(|| field(&rec, "cmd")).unwrap_or_default(),
225                truncate_key(&key, 80)
226            );
227            let log = LogLine {
228                level: rec.level.clone(),
229                target: rec.target.clone(),
230                message: msg,
231                request_id: request_id.clone(),
232                at_ms: now_ms(),
233            };
234            open_bags::with_open(request_id.as_deref(), |bag| bag.push_log(log.clone()));
235            hub.push_log(log);
236            return;
237        }
238
239        if target.starts_with("sova.tasks") {
240            let name = field(&rec, "name").unwrap_or_else(|| "job".into());
241            let status = field(&rec, "status").unwrap_or_else(|| rec.message.clone());
242            let detail = field(&rec, "id")
243                .map(|id| {
244                    let q = field(&rec, "queue").unwrap_or_default();
245                    if q.is_empty() {
246                        id
247                    } else {
248                        format!("queue={q} id={id}")
249                    }
250                })
251                .or_else(|| field(&rec, "queue"));
252            let duration_ms = field(&rec, "duration_ms").and_then(|s| s.parse().ok());
253            let job = JobLine {
254                name,
255                status,
256                detail,
257                duration_ms,
258            };
259            open_bags::with_open(request_id.as_deref(), |bag| bag.push_job(job));
260            let log = LogLine {
261                level: rec.level.clone(),
262                target: rec.target.clone(),
263                message: format!("[tasks] {}", rec.message),
264                request_id: request_id.clone(),
265                at_ms: now_ms(),
266            };
267            open_bags::with_open(request_id.as_deref(), |bag| bag.push_log(log.clone()));
268            hub.push_log(log);
269            return;
270        }
271
272        let is_sql = target.starts_with("sqlx::query")
273            || target.contains("sea_orm")
274            || rec.message.contains("SELECT")
275            || rec.message.contains("INSERT")
276            || rec.message.contains("UPDATE")
277            || rec.message.contains("DELETE");
278
279        let is_http_client = target.contains("http.client")
280            || rec.message.contains("http.client")
281            || rec.message == "http.client done"
282            || rec.message == "http.client error";
283
284        if is_sql {
285            let sql = redact_sql_bindings(&rec.message);
286            let duration_ms = field(&rec, "elapsed")
287                .or_else(|| field(&rec, "duration_ms"))
288                .and_then(|v| v.trim_matches('"').parse::<f64>().ok());
289            let line = LogLine {
290                level: rec.level.clone(),
291                target: rec.target.clone(),
292                message: format!("[sql] {sql}"),
293                request_id: request_id.clone(),
294                at_ms: now_ms(),
295            };
296            open_bags::with_open(request_id.as_deref(), |bag| bag.push_log(line.clone()));
297            hub.push_log(line);
298            attach_query_to_open(
299                request_id.as_deref(),
300                QueryLine {
301                    sql,
302                    duration_ms,
303                    rows: None,
304                },
305            );
306            return;
307        }
308
309        if is_http_client {
310            let method = field(&rec, "http.method")
311                .or_else(|| field(&rec, "method"))
312                .unwrap_or_else(|| "?".into());
313            let url = field(&rec, "http.url")
314                .or_else(|| field(&rec, "url"))
315                .or_else(|| field(&rec, "uri"))
316                .unwrap_or_else(|| rec.message.clone());
317            let status = field(&rec, "status").and_then(|s| s.parse().ok());
318            let duration_ms = field(&rec, "duration_ms").and_then(|s| s.parse().ok());
319            let error = field(&rec, "error");
320            attach_http_to_open(
321                request_id.as_deref(),
322                HttpLine {
323                    method,
324                    url,
325                    status,
326                    duration_ms,
327                    error,
328                },
329            );
330            // Still fall through to logs for visibility.
331        }
332
333        let message = if rec.message == "request" {
334            let method = field(&rec, "method").unwrap_or_else(|| "?".into());
335            let path = field(&rec, "path").unwrap_or_else(|| "?".into());
336            let status = field(&rec, "status").unwrap_or_else(|| "?".into());
337            let ms = field(&rec, "latency_ms").unwrap_or_else(|| "?".into());
338            format!("{method} {path} → {status} ({ms}ms)")
339        } else {
340            rec.message.clone()
341        };
342
343        let line = LogLine {
344            level: rec.level,
345            target: rec.target,
346            message,
347            request_id: request_id.clone(),
348            at_ms: now_ms(),
349        };
350        open_bags::with_open(request_id.as_deref(), |bag| bag.push_log(line.clone()));
351        hub.push_log(line);
352    }));
353}
354
355fn field(rec: &LogRecord, name: &str) -> Option<String> {
356    rec.fields
357        .iter()
358        .find(|(k, _)| k == name)
359        .map(|(_, v)| v.trim_matches('"').to_string())
360}
361
362/// Registry of in-flight bags by request_id for tracing hooks.
363pub(crate) mod open_bags {
364    use crate::collector::DevToolsBag;
365    use std::collections::HashMap;
366    use std::sync::{Mutex, OnceLock};
367
368    static MAP: OnceLock<Mutex<HashMap<String, DevToolsBag>>> = OnceLock::new();
369
370    fn map() -> &'static Mutex<HashMap<String, DevToolsBag>> {
371        MAP.get_or_init(|| Mutex::new(HashMap::new()))
372    }
373
374    pub fn insert(bag: &DevToolsBag) {
375        if bag.request_id == "-" {
376            return;
377        }
378        map()
379            .lock()
380            .unwrap()
381            .insert(bag.request_id.clone(), bag.clone());
382    }
383
384    pub fn remove(request_id: &str) {
385        map().lock().unwrap().remove(request_id);
386    }
387
388    pub fn with_open(request_id: Option<&str>, f: impl FnOnce(&DevToolsBag)) {
389        let Some(id) = request_id else {
390            return;
391        };
392        let g = map().lock().unwrap();
393        if let Some(bag) = g.get(id) {
394            f(bag);
395        }
396    }
397}
398
399fn attach_query_to_open(request_id: Option<&str>, q: QueryLine) {
400    open_bags::with_open(request_id, |bag| bag.push_query(q));
401}
402
403fn attach_http_to_open(request_id: Option<&str>, h: HttpLine) {
404    open_bags::with_open(request_id, |bag| bag.push_http(h));
405}