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 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 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 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 }
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
362pub(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}