1use std::collections::HashMap;
2
3use regex::Regex;
4
5use crate::ir::{FrameCategory, FrameId, FrameKind, ProfileIR};
6
7use super::{CalleeStats, CallerCalleeAnalyzer, CallerStats};
8
9#[derive(Debug, Clone)]
11pub struct FunctionStats {
12 pub frame_id: FrameId,
14 pub name: String,
16 pub location: String,
18 pub category: FrameCategory,
20 pub self_time: u64,
22 pub total_time: u64,
24 pub self_samples: u32,
26 pub total_samples: u32,
28 pub max_recursion_depth: u32,
30 pub recursive_samples: u32,
32 pub first_seen_us: u64,
34 pub last_seen_us: u64,
36}
37
38impl FunctionStats {
39 #[expect(clippy::cast_precision_loss)]
41 pub fn self_percent(&self, total: u64) -> f64 {
42 if total == 0 {
43 0.0
44 } else {
45 (self.self_time as f64 / total as f64) * 100.0
46 }
47 }
48
49 #[expect(clippy::cast_precision_loss)]
51 pub fn total_percent(&self, total: u64) -> f64 {
52 if total == 0 {
53 0.0
54 } else {
55 (self.total_time as f64 / total as f64) * 100.0
56 }
57 }
58
59 #[expect(clippy::cast_precision_loss)]
61 pub fn avg_time_per_sample(&self) -> f64 {
62 if self.self_samples == 0 {
63 0.0
64 } else {
65 self.self_time as f64 / self.self_samples as f64
66 }
67 }
68
69 pub fn performance_pattern(&self, total_samples: usize) -> PerformancePattern {
71 let call_frequency = if total_samples > 0 {
72 self.self_samples as f64 / total_samples as f64
73 } else {
74 0.0
75 };
76 let avg_time = self.avg_time_per_sample();
77
78 let high_frequency = call_frequency > 0.01;
81 let high_cost = avg_time > 1000.0; match (high_frequency, high_cost) {
84 (true, true) => PerformancePattern::CriticalPath,
85 (false, true) => PerformancePattern::ExpensiveOperation,
86 (true, false) => PerformancePattern::FrequentlyCalled,
87 (false, false) => PerformancePattern::Normal,
88 }
89 }
90
91 pub fn is_recursive(&self) -> bool {
93 self.max_recursion_depth > 0
94 }
95
96 pub fn active_span_us(&self) -> u64 {
98 self.last_seen_us.saturating_sub(self.first_seen_us)
99 }
100
101 pub fn async_wait_us(&self) -> u64 {
103 self.active_span_us().saturating_sub(self.total_time)
104 }
105
106 #[expect(clippy::cast_precision_loss)]
108 pub fn async_ratio(&self) -> f64 {
109 if self.total_time == 0 {
110 return 0.0;
111 }
112 self.active_span_us() as f64 / self.total_time as f64
113 }
114
115 pub fn is_async_heavy(&self) -> bool {
118 self.async_ratio() > 2.0 && self.async_wait_us() > 100_000
119 }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum PerformancePattern {
125 CriticalPath,
127 ExpensiveOperation,
129 FrequentlyCalled,
131 Normal,
133}
134
135#[derive(Debug, Clone, Default)]
137pub struct CategoryBreakdown {
138 pub app: u64,
139 pub deps: u64,
140 pub node_internal: u64,
141 pub v8_internal: u64,
142 pub native: u64,
143}
144
145#[derive(Debug, Clone, Default)]
147pub struct CategoryBreakdownInclusive {
148 pub app: u64,
149 pub deps: u64,
150 pub node_internal: u64,
151 pub v8_internal: u64,
152 pub native: u64,
153}
154
155impl CategoryBreakdownInclusive {
156 pub fn total(&self) -> u64 {
158 self.app + self.deps + self.node_internal + self.v8_internal + self.native
159 }
160}
161
162#[derive(Debug, Clone, Default)]
164pub struct CategoryCallFlow {
165 pub calls: std::collections::HashMap<(FrameCategory, FrameCategory), u64>,
167}
168
169impl CategoryCallFlow {
170 pub fn get(&self, caller: FrameCategory, callee: FrameCategory) -> u64 {
172 self.calls.get(&(caller, callee)).copied().unwrap_or(0)
173 }
174
175 pub fn callees_for(&self, caller: FrameCategory) -> Vec<(FrameCategory, u64)> {
177 let mut result: Vec<_> = self
178 .calls
179 .iter()
180 .filter(|((c, _), _)| *c == caller)
181 .map(|((_, callee), &time)| (*callee, time))
182 .collect();
183 result.sort_by(|a, b| b.1.cmp(&a.1));
184 result
185 }
186}
187
188impl CategoryBreakdown {
189 pub fn total(&self) -> u64 {
191 self.app + self.deps + self.node_internal + self.v8_internal + self.native
192 }
193
194 #[expect(clippy::cast_precision_loss)]
196 pub fn percent(&self, category: FrameCategory) -> f64 {
197 let total = self.total();
198 if total == 0 {
199 return 0.0;
200 }
201 let value = match category {
202 FrameCategory::App => self.app,
203 FrameCategory::Deps => self.deps,
204 FrameCategory::NodeInternal => self.node_internal,
205 FrameCategory::V8Internal => self.v8_internal,
206 FrameCategory::Native => self.native,
207 };
208 (value as f64 / total as f64) * 100.0
209 }
210}
211
212#[derive(Debug, Clone)]
214pub struct HotPath {
215 pub frames: Vec<FrameId>,
217 pub time: u64,
219 pub percent: f64,
221 pub sample_count: u32,
223 pub first_seen_us: u64,
225 pub last_seen_us: u64,
227}
228
229impl HotPath {
230 pub fn active_span_us(&self) -> u64 {
232 self.last_seen_us.saturating_sub(self.first_seen_us)
233 }
234
235 pub fn async_wait_us(&self) -> u64 {
237 self.active_span_us().saturating_sub(self.time)
238 }
239
240 pub fn async_ratio(&self) -> f64 {
242 if self.time == 0 {
243 return 0.0;
244 }
245 self.active_span_us() as f64 / self.time as f64
246 }
247
248 pub fn is_async_heavy(&self) -> bool {
250 self.async_ratio() > 2.0 && self.async_wait_us() > 100_000
251 }
252}
253
254#[derive(Debug, Clone)]
256pub struct FileStats {
257 pub file: String,
259 pub self_time: u64,
261 pub total_time: u64,
263 pub call_count: u32,
265 pub category: FrameCategory,
267}
268
269#[derive(Debug, Clone)]
271pub struct PackageStats {
272 pub package: String,
274 pub time: u64,
276 pub percent_of_deps: f64,
278 pub top_function: String,
280 pub top_function_location: String,
282}
283
284#[derive(Debug, Clone)]
286pub struct HotFunctionDetail {
287 pub frame_id: FrameId,
289 pub name: String,
291 pub location: String,
293 pub self_time: u64,
295 pub total_time: u64,
297 pub callers: Vec<CallerStats>,
299 pub callees: Vec<CalleeStats>,
301 pub first_seen_us: u64,
303 pub last_seen_us: u64,
305}
306
307impl HotFunctionDetail {
308 pub fn active_span_us(&self) -> u64 {
310 self.last_seen_us.saturating_sub(self.first_seen_us)
311 }
312
313 pub fn is_async_heavy(&self) -> bool {
315 let async_wait = self.active_span_us().saturating_sub(self.total_time);
316 let ratio = if self.total_time > 0 {
317 self.active_span_us() as f64 / self.total_time as f64
318 } else {
319 0.0
320 };
321 ratio > 2.0 && async_wait > 100_000
322 }
323}
324
325#[derive(Debug, Clone, Default)]
327pub struct ProfileMetadata {
328 pub source_file: Option<String>,
330 pub duration_ms: f64,
332 pub wall_time_ms: Option<f64>,
334 pub sample_count: usize,
336 pub sample_interval_ms: f64,
338 pub internals_filtered: bool,
340 pub sourcemaps_loaded: usize,
342 pub sourcemaps_inline: usize,
344 pub focus_package: Option<String>,
346 pub profiles_merged: usize,
348 pub filter_categories: Vec<FrameCategory>,
350}
351
352impl ProfileMetadata {
353 #[expect(clippy::cast_precision_loss)]
356 pub fn cpu_utilization(&self) -> Option<f64> {
357 self.wall_time_ms.map(|wall| {
358 if wall > 0.0 {
359 self.duration_ms / wall
360 } else {
361 0.0
362 }
363 })
364 }
365}
366
367#[derive(Debug, Clone)]
369pub struct PhaseAnalysis {
370 pub startup: PhaseStats,
372 pub steady_state: PhaseStats,
374 pub total_duration_us: u64,
376}
377
378#[derive(Debug, Clone)]
380pub struct PhaseStats {
381 pub name: String,
383 pub start_us: u64,
385 pub end_us: u64,
387 pub sample_count: usize,
389 pub top_functions: Vec<PhaseFunctionStats>,
391 pub category_breakdown: CategoryBreakdown,
393}
394
395#[derive(Debug, Clone)]
397pub struct PhaseFunctionStats {
398 pub name: String,
400 pub location: String,
402 pub self_time: u64,
404 pub percent: f64,
406 pub category: FrameCategory,
408}
409
410#[derive(Debug, Clone)]
412pub struct RecursiveFunctionStats {
413 pub name: String,
415 pub location: String,
417 pub max_depth: u32,
419 pub recursive_samples: u32,
421 pub total_samples: u32,
423 pub recursive_time: u64,
425 pub total_self_time: u64,
427}
428
429#[derive(Debug, Clone)]
431pub struct GcAnalysis {
432 pub total_time: u64,
434 pub sample_count: u32,
436 pub avg_pause_us: u64,
438 pub allocation_hotspots: Vec<AllocationHotspot>,
440 pub startup_gc_time: u64,
442 pub steady_gc_time: u64,
444}
445
446#[derive(Debug, Clone)]
448pub struct AllocationHotspot {
449 pub name: String,
451 pub location: String,
453 pub category: FrameCategory,
455 pub gc_samples: u32,
457 pub total_samples: u32,
459 pub gc_correlation: f64,
461}
462
463#[derive(Debug)]
465pub struct CpuAnalysis {
466 pub total_time: u64,
468 pub total_samples: usize,
470 pub functions: Vec<FunctionStats>,
472 pub functions_by_total: Vec<FunctionStats>,
474 pub category_breakdown: CategoryBreakdown,
476 pub category_breakdown_inclusive: CategoryBreakdownInclusive,
478 pub category_call_flow: CategoryCallFlow,
480 pub hot_paths: Vec<HotPath>,
482 pub file_stats: Vec<FileStats>,
484 pub package_stats: Vec<PackageStats>,
486 pub hot_function_details: Vec<HotFunctionDetail>,
488 pub gc_time: u64,
490 pub gc_analysis: Option<GcAnalysis>,
492 pub native_time: u64,
494 pub metadata: ProfileMetadata,
496 pub phase_analysis: Option<PhaseAnalysis>,
498 pub recursive_functions: Vec<RecursiveFunctionStats>,
500}
501
502pub struct CpuAnalyzer {
504 min_percent: f64,
506 top_n: usize,
508 include_internals: bool,
510 filter_categories: Vec<FrameCategory>,
512 filter_package: Option<String>,
514 focus_pattern: Option<Regex>,
516 exclude_pattern: Option<Regex>,
518}
519
520impl CpuAnalyzer {
521 pub fn new() -> Self {
523 Self {
524 min_percent: 0.0,
525 top_n: 50,
526 include_internals: false,
527 filter_categories: vec![],
528 filter_package: None,
529 focus_pattern: None,
530 exclude_pattern: None,
531 }
532 }
533
534 pub fn min_percent(mut self, percent: f64) -> Self {
536 self.min_percent = percent;
537 self
538 }
539
540 pub fn top_n(mut self, n: usize) -> Self {
542 self.top_n = n;
543 self
544 }
545
546 pub fn include_internals(mut self, include: bool) -> Self {
548 self.include_internals = include;
549 self
550 }
551
552 pub fn filter_categories(mut self, categories: Vec<FrameCategory>) -> Self {
554 self.filter_categories = categories;
555 self
556 }
557
558 pub fn filter_package(mut self, package: String) -> Self {
560 self.filter_package = Some(package);
561 self
562 }
563
564 pub fn focus(mut self, pattern: Regex) -> Self {
566 self.focus_pattern = Some(pattern);
567 self
568 }
569
570 pub fn exclude(mut self, pattern: Regex) -> Self {
572 self.exclude_pattern = Some(pattern);
573 self
574 }
575
576 fn is_in_package(file: &str, package: &str) -> bool {
578 if file.contains(&format!("/{package}/"))
586 || file.contains(&format!("/{package}:"))
587 || file.ends_with(&format!("/{package}"))
588 {
589 return true;
590 }
591
592 if file.contains(".pnpm/") {
595 let pnpm_pattern = format!(".pnpm/{package}@");
596 let pnpm_scoped = format!(".pnpm/{}+", package.replace('/', "+"));
597 if file.contains(&pnpm_pattern) || file.contains(&pnpm_scoped) {
598 return true;
599 }
600 }
601
602 false
603 }
604
605 fn should_include_frame(&self, frame: &crate::ir::Frame) -> bool {
608 if !self.filter_categories.is_empty() && !self.filter_categories.contains(&frame.category) {
610 return false;
611 }
612
613 if self.filter_package.is_none() && !self.include_internals && frame.category.is_internal()
615 {
616 return false;
617 }
618
619 if let Some(ref pkg) = self.filter_package {
621 if let Some(ref file) = frame.file {
622 if !Self::is_in_package(file, pkg) {
623 return false;
624 }
625 } else {
626 return false;
627 }
628 }
629
630 let name = frame.display_name();
631 let location = frame.location();
632
633 if let Some(ref pattern) = self.focus_pattern {
635 if !pattern.is_match(&name) && !pattern.is_match(&location) {
636 return false;
637 }
638 }
639
640 if let Some(ref pattern) = self.exclude_pattern {
642 if pattern.is_match(&name) || pattern.is_match(&location) {
643 return false;
644 }
645 }
646
647 true
648 }
649
650 #[expect(clippy::cast_precision_loss)]
652 #[expect(clippy::too_many_lines)]
653 pub fn analyze(&self, profile: &ProfileIR) -> CpuAnalysis {
654 let total_time = profile.total_weight();
655 let total_samples = profile.sample_count();
656
657 let mut self_times: HashMap<FrameId, u64> = HashMap::new();
659 let mut total_times: HashMap<FrameId, u64> = HashMap::new();
660 let mut self_counts: HashMap<FrameId, u32> = HashMap::new();
661 let mut total_counts: HashMap<FrameId, u32> = HashMap::new();
662 let mut category_breakdown = CategoryBreakdown::default();
663 let mut category_breakdown_inclusive = CategoryBreakdownInclusive::default();
664 let mut category_call_flow = CategoryCallFlow::default();
665 let mut stack_times: HashMap<Vec<FrameId>, (u64, u32, u64, u64)> = HashMap::new(); let mut gc_time: u64 = 0;
669 let mut gc_samples: u32 = 0;
670 let mut gc_stack_frames: HashMap<FrameId, u32> = HashMap::new(); let mut gc_sample_timestamps: Vec<u64> = Vec::new(); let mut native_time: u64 = 0;
673
674 let mut recursion_stats: HashMap<FrameId, (u32, u32, u64)> = HashMap::new();
676
677 let mut sample_timestamps: Vec<(u64, usize)> = Vec::new();
679
680 let mut file_self_times: HashMap<String, u64> = HashMap::new();
682 let mut file_total_times: HashMap<String, u64> = HashMap::new();
683 let mut file_call_counts: HashMap<String, u32> = HashMap::new();
684 let mut file_categories: HashMap<String, FrameCategory> = HashMap::new();
685
686 let mut package_times: HashMap<String, u64> = HashMap::new();
688 let mut package_top_funcs: HashMap<String, (String, String, u64)> = HashMap::new(); let mut first_seen: HashMap<FrameId, u64> = HashMap::new();
692 let mut last_seen: HashMap<FrameId, u64> = HashMap::new();
693
694 for (sample_idx, sample) in profile.samples.iter().enumerate() {
695 let weight = sample.weight;
696
697 sample_timestamps.push((sample.timestamp_us, sample_idx));
699
700 if let Some(stack) = profile.get_stack(sample.stack_id) {
701 let mut frame_counts: HashMap<FrameId, u32> = HashMap::new();
703 for &frame_id in &stack.frames {
704 *frame_counts.entry(frame_id).or_default() += 1;
705 }
706 for (&frame_id, &count) in &frame_counts {
708 if count > 1 {
709 let depth = count - 1; let entry = recursion_stats.entry(frame_id).or_insert((0, 0, 0));
711 entry.0 = entry.0.max(depth); entry.1 += 1; entry.2 += weight; }
715 }
716
717 let timestamp = sample.timestamp_us;
719 for &frame_id in &stack.frames {
720 first_seen.entry(frame_id).or_insert(timestamp);
722 last_seen.insert(frame_id, timestamp);
724 }
725
726 if let Some(&leaf_frame) = stack.frames.last() {
728 *self_times.entry(leaf_frame).or_default() += weight;
729 *self_counts.entry(leaf_frame).or_default() += 1;
730
731 if let Some(frame) = profile.get_frame(leaf_frame) {
733 match frame.category {
734 FrameCategory::App => category_breakdown.app += weight,
735 FrameCategory::Deps => category_breakdown.deps += weight,
736 FrameCategory::NodeInternal => {
737 category_breakdown.node_internal += weight;
738 }
739 FrameCategory::V8Internal => category_breakdown.v8_internal += weight,
740 FrameCategory::Native => category_breakdown.native += weight,
741 }
742
743 match frame.kind {
745 FrameKind::GC => {
746 gc_time += weight;
747 gc_samples += 1;
748 gc_sample_timestamps.push(sample.timestamp_us);
749 for &frame_id in &stack.frames {
751 if frame_id != leaf_frame {
752 *gc_stack_frames.entry(frame_id).or_default() += 1;
754 }
755 }
756 }
757 FrameKind::Native => native_time += weight,
758 _ => {}
759 }
760
761 if let Some(file) = frame.clean_file() {
763 *file_self_times.entry(file.clone()).or_default() += weight;
764 file_categories.entry(file).or_insert(frame.category);
765 }
766
767 if frame.category == FrameCategory::Deps {
769 if let Some(file) = frame.clean_file() {
770 if let Some(pkg) = Self::extract_package_name(&file) {
771 *package_times.entry(pkg.clone()).or_default() += weight;
772
773 let current_self =
775 self_times.get(&frame.id).copied().unwrap_or(0);
776 package_top_funcs
777 .entry(pkg)
778 .and_modify(|(name, loc, time)| {
779 if weight > *time {
780 *name = frame.display_name().to_string();
781 *loc = frame.location();
782 *time = weight;
783 }
784 })
785 .or_insert((
786 frame.display_name().to_string(),
787 frame.location(),
788 current_self,
789 ));
790 }
791 }
792 }
793 }
794 }
795
796 let mut stack_has_app = false;
799 let mut stack_has_deps = false;
800 let mut stack_has_node = false;
801 let mut stack_has_v8 = false;
802 let mut stack_has_native = false;
803
804 let mut prev_category: Option<FrameCategory> = None;
806
807 for &frame_id in &stack.frames {
808 *total_times.entry(frame_id).or_default() += weight;
809 *total_counts.entry(frame_id).or_default() += 1;
810
811 if let Some(frame) = profile.get_frame(frame_id) {
813 if let Some(file) = frame.clean_file() {
814 *file_total_times.entry(file.clone()).or_default() += weight;
815 *file_call_counts.entry(file.clone()).or_default() += 1;
816 file_categories.entry(file).or_insert(frame.category);
817 }
818
819 match frame.category {
821 FrameCategory::App => stack_has_app = true,
822 FrameCategory::Deps => stack_has_deps = true,
823 FrameCategory::NodeInternal => stack_has_node = true,
824 FrameCategory::V8Internal => stack_has_v8 = true,
825 FrameCategory::Native => stack_has_native = true,
826 }
827
828 if let Some(prev) = prev_category {
830 if prev != frame.category {
831 *category_call_flow
832 .calls
833 .entry((prev, frame.category))
834 .or_default() += weight;
835 }
836 }
837 prev_category = Some(frame.category);
838 }
839 }
840
841 if stack_has_app {
843 category_breakdown_inclusive.app += weight;
844 }
845 if stack_has_deps {
846 category_breakdown_inclusive.deps += weight;
847 }
848 if stack_has_node {
849 category_breakdown_inclusive.node_internal += weight;
850 }
851 if stack_has_v8 {
852 category_breakdown_inclusive.v8_internal += weight;
853 }
854 if stack_has_native {
855 category_breakdown_inclusive.native += weight;
856 }
857
858 let timestamp = sample.timestamp_us;
860 let entry = stack_times
861 .entry(stack.frames.clone())
862 .or_insert((0, 0, timestamp, timestamp));
863 entry.0 += weight;
864 entry.1 += 1;
865 entry.3 = timestamp;
867 }
868 }
869
870 let all_functions: Vec<FunctionStats> = profile
872 .frames
873 .iter()
874 .filter_map(|frame| {
875 let self_time = self_times.get(&frame.id).copied().unwrap_or(0);
876 let frame_total_time = total_times.get(&frame.id).copied().unwrap_or(0);
877
878 if self_time == 0 && frame_total_time == 0 {
880 return None;
881 }
882
883 let (max_depth, rec_samples, _) =
884 recursion_stats.get(&frame.id).copied().unwrap_or((0, 0, 0));
885
886 Some(FunctionStats {
887 frame_id: frame.id,
888 name: frame.display_name().to_string(),
889 location: frame.location(),
890 category: frame.category,
891 self_time,
892 total_time: frame_total_time,
893 self_samples: self_counts.get(&frame.id).copied().unwrap_or(0),
894 total_samples: total_counts.get(&frame.id).copied().unwrap_or(0),
895 max_recursion_depth: max_depth,
896 recursive_samples: rec_samples,
897 first_seen_us: first_seen.get(&frame.id).copied().unwrap_or(0),
898 last_seen_us: last_seen.get(&frame.id).copied().unwrap_or(0),
899 })
900 })
901 .collect();
902
903 let mut functions: Vec<FunctionStats> = all_functions
905 .iter()
906 .filter(|func| {
907 if let Some(frame) = profile.get_frame(func.frame_id) {
909 if !self.should_include_frame(frame) {
910 return false;
911 }
912 } else {
913 return false;
914 }
915
916 let self_pct = if total_time > 0 {
918 (func.self_time as f64 / total_time as f64) * 100.0
919 } else {
920 0.0
921 };
922 if self_pct < self.min_percent && self.min_percent > 0.0 {
923 return false;
924 }
925
926 true
927 })
928 .cloned()
929 .collect();
930
931 functions.sort_by(|a, b| {
933 b.self_time
934 .cmp(&a.self_time)
935 .then_with(|| a.name.cmp(&b.name))
936 });
937 functions.truncate(self.top_n);
938
939 let mut functions_by_total = functions.clone();
941 functions_by_total.sort_by(|a, b| {
942 b.total_time
943 .cmp(&a.total_time)
944 .then_with(|| a.name.cmp(&b.name))
945 });
946
947 let mut hot_paths: Vec<HotPath> = stack_times
949 .into_iter()
950 .filter(|(frames, _)| {
951 if !self.include_internals
953 && !frames.iter().any(|&fid| {
954 profile
955 .get_frame(fid)
956 .is_some_and(|f| !f.category.is_internal())
957 })
958 {
959 return false;
960 }
961
962 if !self.filter_categories.is_empty() {
967 let first_visible_frame = frames.iter().find_map(|&fid| {
968 profile.get_frame(fid).filter(|f| !f.category.is_internal())
969 });
970 let matches = first_visible_frame
971 .is_some_and(|f| self.filter_categories.contains(&f.category));
972 if !matches {
973 return false;
974 }
975 }
976
977 true
978 })
979 .map(
980 |(frames, (time, sample_count, first_seen_us, last_seen_us))| {
981 let percent = if total_time > 0 {
982 (time as f64 / total_time as f64) * 100.0
983 } else {
984 0.0
985 };
986 HotPath {
987 frames,
988 time,
989 percent,
990 sample_count,
991 first_seen_us,
992 last_seen_us,
993 }
994 },
995 )
996 .collect();
997
998 hot_paths.sort_by(|a, b| b.time.cmp(&a.time));
1000
1001 let hot_paths = Self::deduplicate_prefix_paths(hot_paths);
1004
1005 let hot_paths: Vec<_> = hot_paths.into_iter().take(10).collect();
1006
1007 let mut file_stats: Vec<FileStats> = file_total_times
1009 .iter()
1010 .map(|(file, &file_total_time)| {
1011 let self_time = file_self_times.get(file).copied().unwrap_or(0);
1012 let call_count = file_call_counts.get(file).copied().unwrap_or(0);
1013 let category = file_categories
1014 .get(file)
1015 .copied()
1016 .unwrap_or(FrameCategory::App);
1017 FileStats {
1018 file: file.clone(),
1019 self_time,
1020 total_time: file_total_time,
1021 call_count,
1022 category,
1023 }
1024 })
1025 .filter(|fs| {
1026 if !self.include_internals && fs.category.is_internal() {
1028 return false;
1029 }
1030 true
1031 })
1032 .collect();
1033 file_stats.sort_by(|a, b| {
1034 b.self_time
1035 .cmp(&a.self_time)
1036 .then_with(|| a.file.cmp(&b.file))
1037 });
1038 file_stats.truncate(20); let total_deps_time = category_breakdown.deps;
1042 let mut package_stats: Vec<PackageStats> = package_times
1043 .iter()
1044 .map(|(pkg, &time)| {
1045 let (top_func, top_loc, _) = package_top_funcs
1046 .get(pkg)
1047 .cloned()
1048 .unwrap_or_else(|| ("(unknown)".to_string(), "(unknown)".to_string(), 0));
1049 let percent_of_deps = if total_deps_time > 0 {
1050 (time as f64 / total_deps_time as f64) * 100.0
1051 } else {
1052 0.0
1053 };
1054 PackageStats {
1055 package: pkg.clone(),
1056 time,
1057 percent_of_deps,
1058 top_function: top_func,
1059 top_function_location: top_loc,
1060 }
1061 })
1062 .collect();
1063 package_stats.sort_by(|a, b| b.time.cmp(&a.time).then_with(|| a.package.cmp(&b.package)));
1064 package_stats.truncate(15); let caller_callee_analyzer = CallerCalleeAnalyzer::new().top_n(5);
1068 let hot_function_details: Vec<HotFunctionDetail> = functions
1069 .iter()
1070 .take(5) .filter_map(|func| {
1072 caller_callee_analyzer
1073 .analyze(profile, func.frame_id)
1074 .map(|analysis| HotFunctionDetail {
1075 frame_id: func.frame_id,
1076 name: func.name.clone(),
1077 location: func.location.clone(),
1078 self_time: func.self_time,
1079 total_time: func.total_time,
1080 callers: analysis.callers,
1081 callees: analysis.callees,
1082 first_seen_us: func.first_seen_us,
1083 last_seen_us: func.last_seen_us,
1084 })
1085 })
1086 .collect();
1087
1088 let mut recursive_functions: Vec<RecursiveFunctionStats> = recursion_stats
1090 .iter()
1091 .filter_map(|(&frame_id, &(max_depth, rec_samples, rec_time))| {
1092 if max_depth == 0 {
1093 return None;
1094 }
1095 let frame = profile.get_frame(frame_id)?;
1096
1097 if !self.should_include_frame(frame) {
1099 return None;
1100 }
1101
1102 let total_self = self_times.get(&frame_id).copied().unwrap_or(0);
1103 let total_samp = total_counts.get(&frame_id).copied().unwrap_or(0);
1106
1107 Some(RecursiveFunctionStats {
1108 name: frame.display_name().to_string(),
1109 location: frame.location(),
1110 max_depth,
1111 recursive_samples: rec_samples,
1112 total_samples: total_samp,
1113 recursive_time: rec_time,
1114 total_self_time: total_self,
1115 })
1116 })
1117 .collect();
1118 recursive_functions.sort_by(|a, b| {
1119 b.recursive_time
1120 .cmp(&a.recursive_time)
1121 .then_with(|| a.name.cmp(&b.name))
1122 });
1123 recursive_functions.truncate(10); let phase_analysis = if !sample_timestamps.is_empty() && total_time > 0 {
1127 let startup_threshold_us = 500_000u64; let startup_sample_threshold = total_samples / 10;
1130
1131 let startup_end_idx = sample_timestamps
1132 .iter()
1133 .position(|(ts, _)| *ts > startup_threshold_us)
1134 .unwrap_or(sample_timestamps.len())
1135 .min(startup_sample_threshold.max(1));
1136
1137 let startup_end_time = if startup_end_idx < sample_timestamps.len() {
1138 sample_timestamps[startup_end_idx].0
1139 } else {
1140 total_time
1141 };
1142
1143 let startup_stats = self.compute_phase_stats(
1145 profile,
1146 &profile.samples[..startup_end_idx.min(profile.samples.len())],
1147 "Startup",
1148 0,
1149 startup_end_time,
1150 );
1151
1152 let steady_stats = self.compute_phase_stats(
1153 profile,
1154 &profile.samples[startup_end_idx.min(profile.samples.len())..],
1155 "Steady State",
1156 startup_end_time,
1157 total_time,
1158 );
1159
1160 Some(PhaseAnalysis {
1161 startup: startup_stats,
1162 steady_state: steady_stats,
1163 total_duration_us: total_time,
1164 })
1165 } else {
1166 None
1167 };
1168
1169 let gc_analysis = if gc_samples > 0 {
1171 let avg_pause_us = gc_time / u64::from(gc_samples);
1173
1174 let mut hotspots: Vec<AllocationHotspot> = gc_stack_frames
1176 .iter()
1177 .filter_map(|(&frame_id, &gc_count)| {
1178 let frame = profile.get_frame(frame_id)?;
1179 let total_count = total_counts.get(&frame_id).copied().unwrap_or(0);
1180
1181 if gc_count < 2
1184 || (frame.category != FrameCategory::App
1185 && frame.category != FrameCategory::Deps)
1186 {
1187 return None;
1188 }
1189
1190 let gc_correlation = f64::from(gc_count) / f64::from(gc_samples) * 100.0;
1191
1192 Some(AllocationHotspot {
1193 name: frame.display_name().to_string(),
1194 location: frame.location(),
1195 category: frame.category,
1196 gc_samples: gc_count,
1197 total_samples: total_count,
1198 gc_correlation,
1199 })
1200 })
1201 .collect();
1202
1203 hotspots.sort_by(|a, b| {
1205 b.gc_correlation
1206 .partial_cmp(&a.gc_correlation)
1207 .unwrap_or(std::cmp::Ordering::Equal)
1208 .then_with(|| a.name.cmp(&b.name))
1209 });
1210 hotspots.truncate(10);
1211
1212 let startup_end_time = phase_analysis.as_ref().map_or(0, |p| p.startup.end_us);
1214 let startup_gc_time: u64 = gc_sample_timestamps
1215 .iter()
1216 .filter(|&&ts| ts <= startup_end_time)
1217 .count() as u64
1218 * (gc_time / u64::from(gc_samples).max(1));
1219 let steady_gc_time = gc_time.saturating_sub(startup_gc_time);
1220
1221 Some(GcAnalysis {
1222 total_time: gc_time,
1223 sample_count: gc_samples,
1224 avg_pause_us,
1225 allocation_hotspots: hotspots,
1226 startup_gc_time,
1227 steady_gc_time,
1228 })
1229 } else {
1230 None
1231 };
1232
1233 let duration_ms = total_time as f64 / 1000.0;
1235 let wall_time_ms = profile.duration_us.map(|us| us as f64 / 1000.0);
1236 let sample_interval_ms = if total_samples > 0 {
1237 duration_ms / total_samples as f64
1238 } else {
1239 0.0
1240 };
1241 let metadata = ProfileMetadata {
1242 source_file: profile.source_file.clone(),
1243 duration_ms,
1244 wall_time_ms,
1245 sample_count: total_samples,
1246 sample_interval_ms,
1247 internals_filtered: !self.include_internals,
1248 sourcemaps_loaded: profile.sourcemaps_resolved,
1249 sourcemaps_inline: 0,
1250 focus_package: self.filter_package.clone(),
1251 profiles_merged: profile.profiles_merged,
1252 filter_categories: self.filter_categories.clone(),
1253 };
1254
1255 CpuAnalysis {
1256 total_time,
1257 total_samples,
1258 functions,
1259 functions_by_total,
1260 category_breakdown,
1261 category_breakdown_inclusive,
1262 category_call_flow,
1263 hot_paths,
1264 file_stats,
1265 package_stats,
1266 hot_function_details,
1267 gc_time,
1268 gc_analysis,
1269 native_time,
1270 metadata,
1271 phase_analysis,
1272 recursive_functions,
1273 }
1274 }
1275
1276 #[expect(clippy::cast_precision_loss)]
1278 fn compute_phase_stats(
1279 &self,
1280 profile: &ProfileIR,
1281 samples: &[crate::ir::Sample],
1282 name: &str,
1283 start_us: u64,
1284 end_us: u64,
1285 ) -> PhaseStats {
1286 let mut self_times: HashMap<FrameId, u64> = HashMap::new();
1287 let mut category_breakdown = CategoryBreakdown::default();
1288
1289 for sample in samples {
1290 let weight = sample.weight;
1291
1292 if let Some(stack) = profile.get_stack(sample.stack_id) {
1293 if let Some(&leaf_frame) = stack.frames.last() {
1294 *self_times.entry(leaf_frame).or_default() += weight;
1295
1296 if let Some(frame) = profile.get_frame(leaf_frame) {
1297 match frame.category {
1298 FrameCategory::App => category_breakdown.app += weight,
1299 FrameCategory::Deps => category_breakdown.deps += weight,
1300 FrameCategory::NodeInternal => {
1301 category_breakdown.node_internal += weight;
1302 }
1303 FrameCategory::V8Internal => category_breakdown.v8_internal += weight,
1304 FrameCategory::Native => category_breakdown.native += weight,
1305 }
1306 }
1307 }
1308 }
1309 }
1310
1311 let total_phase_time = category_breakdown.total();
1312
1313 let mut top_functions: Vec<PhaseFunctionStats> = self_times
1315 .iter()
1316 .filter_map(|(&frame_id, &self_time)| {
1317 let frame = profile.get_frame(frame_id)?;
1318
1319 if !self.should_include_frame(frame) {
1321 return None;
1322 }
1323
1324 let percent = if total_phase_time > 0 {
1325 (self_time as f64 / total_phase_time as f64) * 100.0
1326 } else {
1327 0.0
1328 };
1329 Some(PhaseFunctionStats {
1330 name: frame.display_name().to_string(),
1331 location: frame.location(),
1332 self_time,
1333 percent,
1334 category: frame.category,
1335 })
1336 })
1337 .collect();
1338
1339 top_functions.sort_by(|a, b| b.self_time.cmp(&a.self_time));
1340 top_functions.truncate(5); PhaseStats {
1343 name: name.to_string(),
1344 start_us,
1345 end_us,
1346 sample_count: samples.len(),
1347 top_functions,
1348 category_breakdown,
1349 }
1350 }
1351
1352 fn extract_package_name(path: &str) -> Option<String> {
1355 let parts: Vec<&str> = path.split("node_modules/").collect();
1357 if parts.len() < 2 {
1358 return None;
1359 }
1360
1361 let after_node_modules = parts.last()?;
1362 let path_parts: Vec<&str> = after_node_modules.split('/').collect();
1363
1364 if path_parts.is_empty() {
1365 return None;
1366 }
1367
1368 if path_parts[0].starts_with('@') && path_parts.len() >= 2 {
1370 let scoped_name = format!("{}/{}", path_parts[0], path_parts[1]);
1373 Some(scoped_name)
1374 } else {
1375 let pkg = path_parts[0];
1377 if let Some(at_pos) = pkg.find('@') {
1379 if at_pos > 0 {
1380 Some(pkg[..at_pos].to_string())
1381 } else {
1382 Some(pkg.to_string())
1383 }
1384 } else {
1385 Some(pkg.to_string())
1386 }
1387 }
1388 }
1389
1390 fn deduplicate_prefix_paths(mut paths: Vec<HotPath>) -> Vec<HotPath> {
1398 if paths.len() <= 1 {
1399 return paths;
1400 }
1401
1402 paths.sort_by(|a, b| b.time.cmp(&a.time));
1404
1405 let mut result: Vec<HotPath> = Vec::new();
1406
1407 for path in paths {
1408 let is_duplicate = result
1410 .iter()
1411 .any(|kept| Self::is_duplicate_path(&path.frames, &kept.frames));
1412
1413 if !is_duplicate {
1414 result.push(path);
1415 }
1416 }
1417
1418 result
1419 }
1420
1421 fn is_duplicate_path(a: &[FrameId], b: &[FrameId]) -> bool {
1427 if a.is_empty() || b.is_empty() {
1428 return false;
1429 }
1430
1431 let common_prefix_len = a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count();
1433 if common_prefix_len >= 2 {
1434 return true;
1435 }
1436
1437 for skip in 1..=3 {
1440 if b.len() <= skip {
1441 break;
1442 }
1443 let matches = a
1445 .iter()
1446 .zip(b.iter().skip(skip))
1447 .take(3)
1448 .all(|(x, y)| x == y);
1449 if matches && a.len() >= 3 {
1450 return true;
1452 }
1453 }
1454
1455 false
1456 }
1457}
1458
1459impl Default for CpuAnalyzer {
1460 fn default() -> Self {
1461 Self::new()
1462 }
1463}