1use 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
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") {
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 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 }
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
359pub(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}