1use 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
11pub 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 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
92fn 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(¤t_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 sova_core::logger_skip_path("/_devtools");
147
148 let profile = current_profile(app);
149 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 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") {
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 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 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
272pub(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}