1mod layer;
25
26pub use layer::DevProfileLayer;
28
29use crate::middleware::HttpLogStyle;
30use axum::http::{Method, StatusCode, Uri};
31use serde::Serialize;
32use std::collections::HashMap;
33use std::path::{Path, PathBuf};
34use std::sync::{Arc, Mutex};
35use std::time::{Duration, Instant};
36use tracing::Span;
37
38const TIME_WIDTH: usize = 8;
40
41const SERVER_TIMING_EXTRA: usize = 4;
43
44#[derive(Debug, Clone)]
46struct SpanRecord {
47 label: String,
49 started: Instant,
51 ended: Option<Instant>,
53 parent: Option<tracing::Id>,
55 source_file: Option<String>,
57 source_line: Option<u32>,
59}
60
61#[derive(Debug, Clone, Serialize)]
63pub struct ProfileHotspot {
64 pub label: String,
66 pub self_ms: f64,
68 pub self_pct: f64,
70 pub file: Option<String>,
72 pub line: Option<u32>,
74}
75
76#[derive(Debug, Clone, Serialize)]
78pub struct ProfileSpanNode {
79 pub label: String,
81 pub self_ms: f64,
83 pub inclusive_ms: f64,
85 pub self_pct: f64,
87 pub file: Option<String>,
89 pub line: Option<u32>,
91}
92
93#[derive(Debug, Clone, Serialize)]
95pub struct ProfileReport {
96 pub method: String,
98 pub uri: String,
100 pub wall_ms: f64,
102 pub status: u16,
104 pub hotspot: Option<ProfileHotspot>,
106 pub spans: Vec<ProfileSpanNode>,
108}
109
110struct PrintNode {
112 depth: usize,
114 label: String,
116 self_ms: f64,
118 inclusive_ms: f64,
120 self_pct: f64,
122 source_file: Option<String>,
124 source_line: Option<u32>,
126}
127
128#[derive(Debug, Default)]
130pub struct RequestProfile {
131 spans: HashMap<tracing::Id, SpanRecord>,
133 order: Vec<tracing::Id>,
135}
136
137impl RequestProfile {
138 pub fn new() -> Self {
140 Self::default()
141 }
142
143 pub fn on_enter(
145 &mut self,
146 id: tracing::Id,
147 label: String,
148 parent: Option<tracing::Id>,
149 source_file: Option<String>,
150 source_line: Option<u32>,
151 ) {
152 if self.spans.contains_key(&id) {
153 return;
154 }
155 self.order.push(id.clone());
156 self.spans.insert(
157 id,
158 SpanRecord {
159 label,
160 started: Instant::now(),
161 ended: None,
162 parent,
163 source_file,
164 source_line,
165 },
166 );
167 }
168
169 pub fn on_close(&mut self, id: tracing::Id) {
171 if let Some(record) = self.spans.get_mut(&id) {
172 record.ended = Some(Instant::now());
173 }
174 }
175
176 fn collect_print_nodes(&self, wall: Duration, min_show: Duration) -> Vec<PrintNode> {
178 if self.spans.is_empty() {
179 return Vec::new();
180 }
181 let wall_ms = wall.as_secs_f64() * 1000.0;
182 let roots = self.root_ids();
183 let mut nodes = Vec::new();
184 for root in &roots {
185 self.collect_nodes(root, 0, wall_ms, min_show, &mut nodes);
186 }
187 nodes
188 }
189
190 pub fn build_report(
192 &self,
193 method: &Method,
194 uri: &Uri,
195 status: StatusCode,
196 wall: Duration,
197 min_show: Duration,
198 ) -> Option<ProfileReport> {
199 let nodes = self.collect_print_nodes(wall, min_show);
200 if nodes.is_empty() {
201 return None;
202 }
203 let wall_ms = wall.as_secs_f64() * 1000.0;
204 let endpoint = format_uri(uri);
205 let hotspot = nodes
206 .iter()
207 .max_by(|a, b| {
208 a.self_ms
209 .partial_cmp(&b.self_ms)
210 .unwrap_or(std::cmp::Ordering::Equal)
211 })
212 .filter(|n| n.self_ms > 0.0)
213 .map(|n| ProfileHotspot {
214 label: n.label.clone(),
215 self_ms: n.self_ms,
216 self_pct: n.self_pct,
217 file: n.source_file.clone(),
218 line: n.source_line,
219 });
220 Some(ProfileReport {
221 method: method.to_string(),
222 uri: endpoint,
223 wall_ms,
224 status: status.as_u16(),
225 hotspot,
226 spans: nodes
227 .iter()
228 .map(|n| ProfileSpanNode {
229 label: n.label.clone(),
230 self_ms: n.self_ms,
231 inclusive_ms: n.inclusive_ms,
232 self_pct: n.self_pct,
233 file: n.source_file.clone(),
234 line: n.source_line,
235 })
236 .collect(),
237 })
238 }
239
240 pub fn format_report(
242 &self,
243 method: &Method,
244 uri: &Uri,
245 wall: Duration,
246 min_show: Duration,
247 ) -> Vec<String> {
248 let nodes = self.collect_print_nodes(wall, min_show);
249 if nodes.is_empty() {
250 return Vec::new();
251 }
252 HttpLogStyle::enable_ansi_support();
253 let style = HttpLogStyle::detect();
254
255 let hotspot_idx = nodes
256 .iter()
257 .enumerate()
258 .max_by(|(_, a), (_, b)| {
259 a.self_ms
260 .partial_cmp(&b.self_ms)
261 .unwrap_or(std::cmp::Ordering::Equal)
262 })
263 .filter(|(_, n)| n.self_ms > 0.0)
264 .map(|(i, _)| i);
265
266 let mut lines = Vec::new();
267 lines.push(style.divider());
268 lines.push(format!(
269 "{} {} {} {}",
270 style.label("prof │"),
271 style.method(method),
272 style.endpoint(uri),
273 style.elapsed(wall),
274 ));
275
276 for (i, node) in nodes.iter().enumerate() {
277 let is_hot = hotspot_idx == Some(i);
278 lines.extend(Self::format_node(node, is_hot, &style));
279 }
280
281 if let Some(idx) = hotspot_idx {
282 if let Some(node) = nodes.get(idx) {
283 let hint = format!(
284 "hotspot {} self {} ({:.1}%)",
285 node.label,
286 format_ms(node.self_ms),
287 node.self_pct,
288 );
289 lines.push(format!(
290 "{} {}",
291 style.label("prof │"),
292 style.hotspot(&hint),
293 ));
294 if let Some(jb) =
295 jetbrains_filter_line(node.source_file.as_deref(), node.source_line)
296 {
297 lines.push(jb);
298 }
299 }
300 }
301
302 lines.push(style.divider());
303 lines
304 }
305
306 pub fn print_report(&self, method: &Method, uri: &Uri, wall: Duration, min_show: Duration) {
308 HttpLogStyle::print_lines(&self.format_report(method, uri, wall, min_show));
309 }
310
311 pub fn format_server_timing(&self, wall: Duration, min_show: Duration) -> Option<String> {
313 if !server_timing_enabled() {
314 return None;
315 }
316 let nodes = self.collect_print_nodes(wall, min_show);
317 if nodes.is_empty() {
318 return None;
319 }
320 let wall_ms = wall.as_secs_f64() * 1000.0;
321 let mut parts = vec![format!("total;dur={wall_ms:.2}")];
322
323 let hotspot_idx = nodes
324 .iter()
325 .enumerate()
326 .max_by(|(_, a), (_, b)| {
327 a.self_ms
328 .partial_cmp(&b.self_ms)
329 .unwrap_or(std::cmp::Ordering::Equal)
330 })
331 .filter(|(_, n)| n.self_ms > 0.0)
332 .map(|(i, _)| i);
333
334 if let Some(idx) = hotspot_idx {
335 let node = &nodes[idx];
336 parts.push(server_timing_metric("hot", node.self_ms, &node.label));
337 }
338
339 let mut ranked: Vec<_> = nodes
340 .iter()
341 .enumerate()
342 .filter(|(i, n)| Some(*i) != hotspot_idx && n.self_ms > 0.0)
343 .collect();
344 ranked.sort_by(|(_, a), (_, b)| {
345 b.self_ms
346 .partial_cmp(&a.self_ms)
347 .unwrap_or(std::cmp::Ordering::Equal)
348 });
349 for (seq, (_, node)) in ranked.into_iter().take(SERVER_TIMING_EXTRA).enumerate() {
350 parts.push(server_timing_metric(
351 &format!("s{}", seq + 1),
352 node.self_ms,
353 &node.label,
354 ));
355 }
356
357 Some(parts.join(", "))
358 }
359
360 fn root_ids(&self) -> Vec<tracing::Id> {
362 self.order
363 .iter()
364 .filter(|id| {
365 self.spans
366 .get(*id)
367 .and_then(|s| s.parent.as_ref())
368 .map(|p| !self.spans.contains_key(p))
369 .unwrap_or(true)
370 })
371 .cloned()
372 .collect()
373 }
374
375 fn collect_nodes(
377 &self,
378 id: &tracing::Id,
379 depth: usize,
380 wall_ms: f64,
381 min_show: Duration,
382 out: &mut Vec<PrintNode>,
383 ) {
384 let Some(record) = self.spans.get(id) else {
385 return;
386 };
387
388 if record.label.ends_with("::http.request") || record.label == "http.request" {
389 for child in self.direct_children(id) {
390 self.collect_nodes(&child, depth, wall_ms, min_show, out);
391 }
392 return;
393 }
394
395 let inclusive = record.duration();
396 let inclusive_ms = inclusive.as_secs_f64() * 1000.0;
397 let children_ms: f64 = self
398 .direct_children(id)
399 .iter()
400 .filter_map(|cid| self.spans.get(cid))
401 .map(|c| c.duration().as_secs_f64() * 1000.0)
402 .sum();
403 let self_ms = (inclusive_ms - children_ms).max(0.0);
404
405 if Duration::from_secs_f64(self_ms / 1000.0) < min_show {
406 for child in self.direct_children(id) {
407 self.collect_nodes(&child, depth + 1, wall_ms, min_show, out);
408 }
409 return;
410 }
411
412 let self_pct = if wall_ms > 0.0 {
413 self_ms / wall_ms * 100.0
414 } else {
415 0.0
416 };
417 out.push(PrintNode {
418 depth,
419 label: record.label.clone(),
420 self_ms,
421 inclusive_ms,
422 self_pct,
423 source_file: record.source_file.clone(),
424 source_line: record.source_line,
425 });
426
427 for child in self.direct_children(id) {
428 self.collect_nodes(&child, depth + 1, wall_ms, min_show, out);
429 }
430 }
431
432 fn format_node(node: &PrintNode, is_hot: bool, style: &HttpLogStyle) -> Vec<String> {
434 let branch = tree_branch(node.depth);
435 let label_col = format_method_label(
436 style,
437 &node.label,
438 node.source_file.as_deref(),
439 node.source_line,
440 );
441
442 let hot_mark = if is_hot { " ◀ hot" } else { "" };
443 let line = format!(
444 "{branch}{label_col} self {:>tw$} incl {:>tw$} {:>5.1}%{hot_mark}",
445 format_ms(node.self_ms),
446 format_ms(node.inclusive_ms),
447 node.self_pct,
448 tw = TIME_WIDTH,
449 );
450
451 let mut lines = vec![format!(
452 "{} {}",
453 style.label("prof │"),
454 style.prof_text(&line, is_hot),
455 )];
456 if let Some(jb) = jetbrains_filter_line(node.source_file.as_deref(), node.source_line) {
457 lines.push(jb);
458 }
459 lines
460 }
461
462 fn direct_children(&self, id: &tracing::Id) -> Vec<tracing::Id> {
464 self.order
465 .iter()
466 .filter(|child| self.spans.get(*child).and_then(|s| s.parent.as_ref()) == Some(id))
467 .cloned()
468 .collect()
469 }
470}
471
472impl SpanRecord {
473 fn duration(&self) -> Duration {
475 self.ended
476 .map(|end| end.duration_since(self.started))
477 .unwrap_or_else(|| self.started.elapsed())
478 }
479}
480
481tokio::task_local! {
482 static REQUEST_PROFILE: Arc<Mutex<RequestProfile>>;
484}
485
486pub fn is_active() -> bool {
488 REQUEST_PROFILE.try_with(|_| ()).is_ok()
489}
490
491pub async fn scope<F>(profile: Arc<Mutex<RequestProfile>>, fut: F) -> F::Output
493where
494 F: std::future::Future,
495{
496 REQUEST_PROFILE.scope(profile, fut).await
497}
498
499pub fn record_enter(
501 id: tracing::Id,
502 label: String,
503 parent: Option<tracing::Id>,
504 source_file: Option<&str>,
505 source_line: Option<u32>,
506) {
507 let _ = REQUEST_PROFILE.try_with(|cell| {
508 if let Ok(mut guard) = cell.lock() {
509 guard.on_enter(
510 id,
511 label,
512 parent,
513 source_file.map(str::to_string),
514 source_line,
515 );
516 }
517 });
518}
519
520pub fn record_close(id: tracing::Id) {
522 let _ = REQUEST_PROFILE.try_with(|cell| {
523 if let Ok(mut guard) = cell.lock() {
524 guard.on_close(id);
525 }
526 });
527}
528
529pub fn span_label(meta: &tracing::Metadata<'_>) -> String {
531 let target = meta.target();
532 let short = target.rsplit("::").take(2).collect::<Vec<_>>();
533 let module = short.into_iter().rev().collect::<Vec<_>>().join("::");
534 format!("{}::{}", module, meta.name())
535}
536
537fn server_timing_enabled() -> bool {
539 std::env::var("DEV_SERVER_TIMING").ok().as_deref() != Some("0")
540}
541
542fn server_timing_metric(name: &str, dur_ms: f64, label: &str) -> String {
544 let desc = escape_server_timing_desc(&server_timing_desc(label));
545 format!("{name};dur={dur_ms:.2};desc=\"{desc}\"")
546}
547
548fn server_timing_desc(label: &str) -> String {
550 let parts: Vec<&str> = label.split("::").collect();
551 if parts.len() <= 2 {
552 label.to_string()
553 } else {
554 parts[parts.len() - 2..].join("::")
555 }
556}
557
558fn escape_server_timing_desc(s: &str) -> String {
560 s.replace('\\', "\\\\").replace('"', "\\\"")
561}
562
563pub fn min_show_duration() -> Duration {
565 std::env::var("DEV_PROFILE_MIN_MS")
566 .ok()
567 .and_then(|v| v.parse::<f64>().ok())
568 .map(|ms| Duration::from_secs_f64(ms / 1000.0))
569 .unwrap_or_else(|| Duration::from_secs_f64(0.0001))
570}
571
572pub fn append_jsonl_report(report: &ProfileReport) {
574 let Ok(path) = std::env::var("DEV_PROFILE_JSONL") else {
575 return;
576 };
577 let Ok(line) = serde_json::to_string(report) else {
578 return;
579 };
580 HttpLogStyle::append_jsonl(&path, &line);
581}
582
583fn format_uri(uri: &Uri) -> String {
585 match uri.query() {
586 Some(q) => format!("{}?{q}", uri.path()),
587 None => uri.path().to_string(),
588 }
589}
590
591pub fn http_request_span(method: &Method, uri: &Uri) -> Span {
593 tracing::debug_span!(
594 "http.request",
595 method = %method,
596 uri = %uri,
597 )
598}
599
600fn tree_branch(depth: usize) -> String {
602 if depth == 0 {
603 "├─ ".to_string()
604 } else {
605 format!("{}└─ ", "│ ".repeat(depth.saturating_sub(1)))
606 }
607}
608
609fn format_ms(ms: f64) -> String {
611 if ms >= 100.0 {
612 format!("{ms:.0}ms")
613 } else if ms >= 10.0 {
614 format!("{ms:.1}ms")
615 } else {
616 format!("{ms:.2}ms")
617 }
618}
619
620enum LinkScheme {
622 JetBrains,
624 Cursor,
626 Vscode,
628 VscodeInsiders,
630 File,
632 None,
634}
635
636impl LinkScheme {
637 fn detect() -> Self {
639 if std::env::var("DEV_PROFILE_NO_LINKS").is_ok() {
640 return Self::None;
641 }
642 match std::env::var("DEV_PROFILE_LINK_SCHEME")
643 .unwrap_or_default()
644 .to_ascii_lowercase()
645 .as_str()
646 {
647 "none" => Self::None,
648 "jetbrains" | "rustrover" | "idea" | "goland" | "webstorm" => Self::JetBrains,
649 "vscode" => Self::Vscode,
650 "vscode-insiders" => Self::VscodeInsiders,
651 "file" => Self::File,
652 "cursor" => Self::Cursor,
653 "" => {
654 let emulator = std::env::var("TERMINAL_EMULATOR").unwrap_or_default();
655 if emulator.contains("JetBrains") {
656 Self::JetBrains
657 } else if std::env::var("TERM_PROGRAM").unwrap_or_default() == "vscode" {
658 Self::Cursor
659 } else {
660 Self::JetBrains
661 }
662 }
663 _ => Self::Cursor,
664 }
665 }
666}
667
668fn format_method_label(
670 style: &HttpLogStyle,
671 method: &str,
672 file: Option<&str>,
673 line: Option<u32>,
674) -> String {
675 if matches!(LinkScheme::detect(), LinkScheme::JetBrains) {
676 return method.to_string();
677 }
678 let Some(uri) = build_osc8_uri(file, line) else {
679 return method.to_string();
680 };
681 style.link(&uri, method)
682}
683
684fn jetbrains_filter_line(file: Option<&str>, line: Option<u32>) -> Option<String> {
686 if !matches!(LinkScheme::detect(), LinkScheme::JetBrains) {
687 return None;
688 }
689 let file = file?;
690 let line = line?;
691 let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
692 let path = resolve_source_path(&manifest, file);
693 let rel = project_relative_path(&path, &manifest);
694 let style = std::env::var("DEV_PROFILE_JB_STYLE").unwrap_or_else(|_| "rustc".into());
695 Some(match style.as_str() {
696 "stack" | "at" => format!(" at {rel}:{line}:1"),
698 _ => format!(" --> {rel}:{line}:1"),
700 })
701}
702
703fn project_relative_path(path: &Path, manifest: &Path) -> String {
705 path.strip_prefix(manifest)
706 .map(path_to_link)
707 .unwrap_or_else(|_| path_to_link(path))
708}
709
710fn build_osc8_uri(file: Option<&str>, line: Option<u32>) -> Option<String> {
712 let file = file?;
713 let line = line?;
714 let scheme = LinkScheme::detect();
715 if matches!(scheme, LinkScheme::None | LinkScheme::JetBrains) {
716 return None;
717 }
718 let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
719 let path = resolve_source_path(&manifest, file);
720 let abs = path_to_link(&path);
721 Some(match scheme {
722 LinkScheme::File => format!("file://{abs}:{line}:1"),
723 LinkScheme::Vscode => format!("vscode://file/{abs}:{line}:1"),
724 LinkScheme::VscodeInsiders => format!("vscode-insiders://file/{abs}:{line}:1"),
725 LinkScheme::Cursor => format!("cursor://file/{abs}:{line}:1"),
726 LinkScheme::JetBrains | LinkScheme::None => return None,
727 })
728}
729
730fn path_to_link(path: &Path) -> String {
732 let raw = path.to_string_lossy();
733 let stripped = raw
734 .strip_prefix(r"\\?\")
735 .or_else(|| raw.strip_prefix("//?/"))
736 .unwrap_or(&raw);
737 stripped.replace('\\', "/")
738}
739
740fn resolve_source_path(manifest: &Path, file: &str) -> PathBuf {
742 let p = PathBuf::from(file);
743 if p.is_absolute() {
744 p
745 } else {
746 manifest.join(p)
747 }
748}