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) = jetbrains_filter_line(node.source_file.as_deref(), node.source_line)
295 {
296 lines.push(jb);
297 }
298 }
299 }
300
301 lines.push(style.divider());
302 lines
303 }
304
305 pub fn print_report(
307 &self,
308 method: &Method,
309 uri: &Uri,
310 wall: Duration,
311 min_show: Duration,
312 ) {
313 HttpLogStyle::print_lines(&self.format_report(method, uri, wall, min_show));
314 }
315
316 pub fn format_server_timing(&self, wall: Duration, min_show: Duration) -> Option<String> {
318 if !server_timing_enabled() {
319 return None;
320 }
321 let nodes = self.collect_print_nodes(wall, min_show);
322 if nodes.is_empty() {
323 return None;
324 }
325 let wall_ms = wall.as_secs_f64() * 1000.0;
326 let mut parts = vec![format!("total;dur={wall_ms:.2}")];
327
328 let hotspot_idx = nodes
329 .iter()
330 .enumerate()
331 .max_by(|(_, a), (_, b)| {
332 a.self_ms
333 .partial_cmp(&b.self_ms)
334 .unwrap_or(std::cmp::Ordering::Equal)
335 })
336 .filter(|(_, n)| n.self_ms > 0.0)
337 .map(|(i, _)| i);
338
339 if let Some(idx) = hotspot_idx {
340 let node = &nodes[idx];
341 parts.push(server_timing_metric("hot", node.self_ms, &node.label));
342 }
343
344 let mut ranked: Vec<_> = nodes
345 .iter()
346 .enumerate()
347 .filter(|(i, n)| Some(*i) != hotspot_idx && n.self_ms > 0.0)
348 .collect();
349 ranked.sort_by(|(_, a), (_, b)| {
350 b.self_ms
351 .partial_cmp(&a.self_ms)
352 .unwrap_or(std::cmp::Ordering::Equal)
353 });
354 for (seq, (_, node)) in ranked
355 .into_iter()
356 .take(SERVER_TIMING_EXTRA)
357 .enumerate()
358 {
359 parts.push(server_timing_metric(
360 &format!("s{}", seq + 1),
361 node.self_ms,
362 &node.label,
363 ));
364 }
365
366 Some(parts.join(", "))
367 }
368
369 fn root_ids(&self) -> Vec<tracing::Id> {
371 self.order
372 .iter()
373 .filter(|id| {
374 self.spans
375 .get(*id)
376 .and_then(|s| s.parent.as_ref())
377 .map(|p| !self.spans.contains_key(p))
378 .unwrap_or(true)
379 })
380 .cloned()
381 .collect()
382 }
383
384 fn collect_nodes(
386 &self,
387 id: &tracing::Id,
388 depth: usize,
389 wall_ms: f64,
390 min_show: Duration,
391 out: &mut Vec<PrintNode>,
392 ) {
393 let Some(record) = self.spans.get(id) else {
394 return;
395 };
396
397 if record.label.ends_with("::http.request") || record.label == "http.request" {
398 for child in self.direct_children(id) {
399 self.collect_nodes(&child, depth, wall_ms, min_show, out);
400 }
401 return;
402 }
403
404 let inclusive = record.duration();
405 let inclusive_ms = inclusive.as_secs_f64() * 1000.0;
406 let children_ms: f64 = self
407 .direct_children(id)
408 .iter()
409 .filter_map(|cid| self.spans.get(cid))
410 .map(|c| c.duration().as_secs_f64() * 1000.0)
411 .sum();
412 let self_ms = (inclusive_ms - children_ms).max(0.0);
413
414 if Duration::from_secs_f64(self_ms / 1000.0) < min_show {
415 for child in self.direct_children(id) {
416 self.collect_nodes(&child, depth + 1, wall_ms, min_show, out);
417 }
418 return;
419 }
420
421 let self_pct = if wall_ms > 0.0 {
422 self_ms / wall_ms * 100.0
423 } else {
424 0.0
425 };
426 out.push(PrintNode {
427 depth,
428 label: record.label.clone(),
429 self_ms,
430 inclusive_ms,
431 self_pct,
432 source_file: record.source_file.clone(),
433 source_line: record.source_line,
434 });
435
436 for child in self.direct_children(id) {
437 self.collect_nodes(&child, depth + 1, wall_ms, min_show, out);
438 }
439 }
440
441 fn format_node(node: &PrintNode, is_hot: bool, style: &HttpLogStyle) -> Vec<String> {
443 let branch = tree_branch(node.depth);
444 let label_col = format_method_label(
445 style,
446 &node.label,
447 node.source_file.as_deref(),
448 node.source_line,
449 );
450
451 let hot_mark = if is_hot { " ◀ hot" } else { "" };
452 let line = format!(
453 "{branch}{label_col} self {:>tw$} incl {:>tw$} {:>5.1}%{hot_mark}",
454 format_ms(node.self_ms),
455 format_ms(node.inclusive_ms),
456 node.self_pct,
457 tw = TIME_WIDTH,
458 );
459
460 let mut lines = vec![format!(
461 "{} {}",
462 style.label("prof │"),
463 style.prof_text(&line, is_hot),
464 )];
465 if let Some(jb) = jetbrains_filter_line(node.source_file.as_deref(), node.source_line) {
466 lines.push(jb);
467 }
468 lines
469 }
470
471 fn direct_children(&self, id: &tracing::Id) -> Vec<tracing::Id> {
473 self.order
474 .iter()
475 .filter(|child| {
476 self.spans
477 .get(*child)
478 .and_then(|s| s.parent.as_ref())
479 == Some(id)
480 })
481 .cloned()
482 .collect()
483 }
484}
485
486impl SpanRecord {
487 fn duration(&self) -> Duration {
489 self.ended
490 .map(|end| end.duration_since(self.started))
491 .unwrap_or_else(|| self.started.elapsed())
492 }
493}
494
495tokio::task_local! {
496 static REQUEST_PROFILE: Arc<Mutex<RequestProfile>>;
498}
499
500pub fn is_active() -> bool {
502 REQUEST_PROFILE.try_with(|_| ()).is_ok()
503}
504
505pub async fn scope<F>(profile: Arc<Mutex<RequestProfile>>, fut: F) -> F::Output
507where
508 F: std::future::Future,
509{
510 REQUEST_PROFILE.scope(profile, fut).await
511}
512
513pub fn record_enter(
515 id: tracing::Id,
516 label: String,
517 parent: Option<tracing::Id>,
518 source_file: Option<&str>,
519 source_line: Option<u32>,
520) {
521 let _ = REQUEST_PROFILE.try_with(|cell| {
522 if let Ok(mut guard) = cell.lock() {
523 guard.on_enter(
524 id,
525 label,
526 parent,
527 source_file.map(str::to_string),
528 source_line,
529 );
530 }
531 });
532}
533
534pub fn record_close(id: tracing::Id) {
536 let _ = REQUEST_PROFILE.try_with(|cell| {
537 if let Ok(mut guard) = cell.lock() {
538 guard.on_close(id);
539 }
540 });
541}
542
543pub fn span_label(meta: &tracing::Metadata<'_>) -> String {
545 let target = meta.target();
546 let short = target.rsplit("::").take(2).collect::<Vec<_>>();
547 let module = short.into_iter().rev().collect::<Vec<_>>().join("::");
548 format!("{}::{}", module, meta.name())
549}
550
551fn server_timing_enabled() -> bool {
553 std::env::var("DEV_SERVER_TIMING").ok().as_deref() != Some("0")
554}
555
556fn server_timing_metric(name: &str, dur_ms: f64, label: &str) -> String {
558 let desc = escape_server_timing_desc(&server_timing_desc(label));
559 format!("{name};dur={dur_ms:.2};desc=\"{desc}\"")
560}
561
562fn server_timing_desc(label: &str) -> String {
564 let parts: Vec<&str> = label.split("::").collect();
565 if parts.len() <= 2 {
566 label.to_string()
567 } else {
568 parts[parts.len() - 2..].join("::")
569 }
570}
571
572fn escape_server_timing_desc(s: &str) -> String {
574 s.replace('\\', "\\\\").replace('"', "\\\"")
575}
576
577pub fn min_show_duration() -> Duration {
579 std::env::var("DEV_PROFILE_MIN_MS")
580 .ok()
581 .and_then(|v| v.parse::<f64>().ok())
582 .map(|ms| Duration::from_secs_f64(ms / 1000.0))
583 .unwrap_or_else(|| Duration::from_secs_f64(0.0001))
584}
585
586pub fn append_jsonl_report(report: &ProfileReport) {
588 let Ok(path) = std::env::var("DEV_PROFILE_JSONL") else {
589 return;
590 };
591 let Ok(line) = serde_json::to_string(report) else {
592 return;
593 };
594 HttpLogStyle::append_jsonl(&path, &line);
595}
596
597fn format_uri(uri: &Uri) -> String {
599 match uri.query() {
600 Some(q) => format!("{}?{q}", uri.path()),
601 None => uri.path().to_string(),
602 }
603}
604
605pub fn http_request_span(method: &Method, uri: &Uri) -> Span {
607 tracing::debug_span!(
608 "http.request",
609 method = %method,
610 uri = %uri,
611 )
612}
613
614fn tree_branch(depth: usize) -> String {
616 if depth == 0 {
617 "├─ ".to_string()
618 } else {
619 format!("{}└─ ", "│ ".repeat(depth.saturating_sub(1)))
620 }
621}
622
623fn format_ms(ms: f64) -> String {
625 if ms >= 100.0 {
626 format!("{ms:.0}ms")
627 } else if ms >= 10.0 {
628 format!("{ms:.1}ms")
629 } else {
630 format!("{ms:.2}ms")
631 }
632}
633
634enum LinkScheme {
636 JetBrains,
638 Cursor,
640 Vscode,
642 VscodeInsiders,
644 File,
646 None,
648}
649
650impl LinkScheme {
651 fn detect() -> Self {
653 if std::env::var("DEV_PROFILE_NO_LINKS").is_ok() {
654 return Self::None;
655 }
656 match std::env::var("DEV_PROFILE_LINK_SCHEME")
657 .unwrap_or_default()
658 .to_ascii_lowercase()
659 .as_str()
660 {
661 "none" => Self::None,
662 "jetbrains" | "rustrover" | "idea" | "goland" | "webstorm" => Self::JetBrains,
663 "vscode" => Self::Vscode,
664 "vscode-insiders" => Self::VscodeInsiders,
665 "file" => Self::File,
666 "cursor" => Self::Cursor,
667 "" => {
668 let emulator = std::env::var("TERMINAL_EMULATOR").unwrap_or_default();
669 if emulator.contains("JetBrains") {
670 Self::JetBrains
671 } else if std::env::var("TERM_PROGRAM").unwrap_or_default() == "vscode" {
672 Self::Cursor
673 } else {
674 Self::JetBrains
675 }
676 }
677 _ => Self::Cursor,
678 }
679 }
680}
681
682fn format_method_label(
684 style: &HttpLogStyle,
685 method: &str,
686 file: Option<&str>,
687 line: Option<u32>,
688) -> String {
689 if matches!(LinkScheme::detect(), LinkScheme::JetBrains) {
690 return method.to_string();
691 }
692 let Some(uri) = build_osc8_uri(file, line) else {
693 return method.to_string();
694 };
695 style.link(&uri, method)
696}
697
698fn jetbrains_filter_line(file: Option<&str>, line: Option<u32>) -> Option<String> {
700 if !matches!(LinkScheme::detect(), LinkScheme::JetBrains) {
701 return None;
702 }
703 let file = file?;
704 let line = line?;
705 let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
706 let path = resolve_source_path(&manifest, file);
707 let rel = project_relative_path(&path, &manifest);
708 let style = std::env::var("DEV_PROFILE_JB_STYLE").unwrap_or_else(|_| "rustc".into());
709 Some(match style.as_str() {
710 "stack" | "at" => format!(" at {rel}:{line}:1"),
712 _ => format!(" --> {rel}:{line}:1"),
714 })
715}
716
717fn project_relative_path(path: &Path, manifest: &Path) -> String {
719 path.strip_prefix(manifest)
720 .map(path_to_link)
721 .unwrap_or_else(|_| path_to_link(path))
722}
723
724fn build_osc8_uri(file: Option<&str>, line: Option<u32>) -> Option<String> {
726 let file = file?;
727 let line = line?;
728 let scheme = LinkScheme::detect();
729 if matches!(scheme, LinkScheme::None | LinkScheme::JetBrains) {
730 return None;
731 }
732 let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
733 let path = resolve_source_path(&manifest, file);
734 let abs = path_to_link(&path);
735 Some(match scheme {
736 LinkScheme::File => format!("file://{abs}:{line}:1"),
737 LinkScheme::Vscode => format!("vscode://file/{abs}:{line}:1"),
738 LinkScheme::VscodeInsiders => format!("vscode-insiders://file/{abs}:{line}:1"),
739 LinkScheme::Cursor => format!("cursor://file/{abs}:{line}:1"),
740 LinkScheme::JetBrains | LinkScheme::None => return None,
741 })
742}
743
744fn path_to_link(path: &Path) -> String {
746 let raw = path.to_string_lossy();
747 let stripped = raw
748 .strip_prefix(r"\\?\")
749 .or_else(|| raw.strip_prefix("//?/"))
750 .unwrap_or(&raw);
751 stripped.replace('\\', "/")
752}
753
754fn resolve_source_path(manifest: &Path, file: &str) -> PathBuf {
756 let p = PathBuf::from(file);
757 if p.is_absolute() {
758 p
759 } else {
760 manifest.join(p)
761 }
762}