Skip to main content

sova_devtools/
plugin.rs

1//! `DevTools` plugin entry.
2
3use crate::collector::{HttpLine, LogLine, QueryLine, now_ms};
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        // Drop access-log noise about the DevTools UI itself (even if logger skip missed it).
171        if let Some(path) = field(&rec, "path") {
172            if path_is_devtools(path.trim_matches('"')) {
173                return;
174            }
175        }
176
177        let request_id = rec
178            .fields
179            .iter()
180            .find(|(k, _)| k == "request_id")
181            .map(|(_, v)| v.trim_matches('"').to_string());
182
183        let target = rec.target.as_str();
184        let is_sql = target.starts_with("sqlx::query")
185            || target.contains("sea_orm")
186            || rec.message.contains("SELECT")
187            || rec.message.contains("INSERT")
188            || rec.message.contains("UPDATE")
189            || rec.message.contains("DELETE");
190
191        let is_http_client =
192            target.contains("http.client") || rec.message.contains("http.client");
193
194        if is_sql {
195            let sql = redact_sql_bindings(&rec.message);
196            let duration_ms = rec
197                .fields
198                .iter()
199                .find(|(k, _)| k == "elapsed" || k.contains("time"))
200                .and_then(|(_, v)| v.trim_matches('"').parse::<f64>().ok());
201            let line = LogLine {
202                level: rec.level.clone(),
203                target: rec.target.clone(),
204                message: format!("[sql] {sql}"),
205                request_id: request_id.clone(),
206                at_ms: now_ms(),
207            };
208            open_bags::with_open(request_id.as_deref(), |bag| bag.push_log(line.clone()));
209            hub.push_log(line);
210            attach_query_to_open(
211                &hub,
212                request_id.as_deref(),
213                QueryLine {
214                    sql,
215                    duration_ms,
216                    rows: None,
217                },
218            );
219            return;
220        }
221
222        if is_http_client {
223            let method = field(&rec, "method").unwrap_or_else(|| "?".into());
224            let url = field(&rec, "uri")
225                .or_else(|| field(&rec, "url"))
226                .unwrap_or_else(|| rec.message.clone());
227            let status = field(&rec, "status").and_then(|s| s.parse().ok());
228            attach_http_to_open(
229                &hub,
230                request_id.as_deref(),
231                HttpLine {
232                    method,
233                    url,
234                    status,
235                    duration_ms: None,
236                    error: None,
237                },
238            );
239        }
240
241        // Format request access lines more readably in the panel.
242        let message = if rec.message == "request" {
243            let method = field(&rec, "method").unwrap_or_else(|| "?".into());
244            let path = field(&rec, "path").unwrap_or_else(|| "?".into());
245            let status = field(&rec, "status").unwrap_or_else(|| "?".into());
246            let ms = field(&rec, "latency_ms").unwrap_or_else(|| "?".into());
247            format!("{method} {path} → {status} ({ms}ms)")
248        } else {
249            rec.message.clone()
250        };
251
252        let line = LogLine {
253            level: rec.level,
254            target: rec.target,
255            message,
256            request_id: request_id.clone(),
257            at_ms: now_ms(),
258        };
259        // Per-request Logs tab + site-wide /_devtools/logs feed.
260        open_bags::with_open(request_id.as_deref(), |bag| bag.push_log(line.clone()));
261        hub.push_log(line);
262    }));
263}
264
265fn field(rec: &LogRecord, name: &str) -> Option<String> {
266    rec.fields
267        .iter()
268        .find(|(k, _)| k == name)
269        .map(|(_, v)| v.trim_matches('"').to_string())
270}
271
272/// Registry of in-flight bags by request_id for tracing hooks.
273pub(crate) mod open_bags {
274    use crate::collector::DevToolsBag;
275    use std::collections::HashMap;
276    use std::sync::{Mutex, OnceLock};
277
278    static MAP: OnceLock<Mutex<HashMap<String, DevToolsBag>>> = OnceLock::new();
279
280    fn map() -> &'static Mutex<HashMap<String, DevToolsBag>> {
281        MAP.get_or_init(|| Mutex::new(HashMap::new()))
282    }
283
284    pub fn insert(bag: &DevToolsBag) {
285        if bag.request_id == "-" {
286            return;
287        }
288        map()
289            .lock()
290            .unwrap()
291            .insert(bag.request_id.clone(), bag.clone());
292    }
293
294    pub fn remove(request_id: &str) {
295        map().lock().unwrap().remove(request_id);
296    }
297
298    pub fn with_open(request_id: Option<&str>, f: impl FnOnce(&DevToolsBag)) {
299        let Some(id) = request_id else {
300            return;
301        };
302        let g = map().lock().unwrap();
303        if let Some(bag) = g.get(id) {
304            f(bag);
305        }
306    }
307}
308
309fn attach_query_to_open(_hub: &DevToolsHub, request_id: Option<&str>, q: QueryLine) {
310    open_bags::with_open(request_id, |bag| bag.push_query(q));
311}
312
313fn attach_http_to_open(_hub: &DevToolsHub, request_id: Option<&str>, h: HttpLine) {
314    open_bags::with_open(request_id, |bag| bag.push_http(h));
315}