Skip to main content

profile_inspect/ir/
sample.rs

1use std::collections::HashMap;
2use std::path::{Component, PathBuf};
3
4use super::{FrameCategory, FrameId, StackId};
5use serde::Serialize;
6
7use crate::sourcemap::SourceMapResolver;
8
9/// Normalize path components (resolve .. and . without requiring file to exist)
10fn normalize_path_components(path: &PathBuf) -> String {
11    let mut components: Vec<Component> = Vec::new();
12
13    for component in path.components() {
14        match component {
15            Component::ParentDir => {
16                // Go up one level if possible
17                if let Some(Component::Normal(_)) = components.last() {
18                    components.pop();
19                } else {
20                    components.push(component);
21                }
22            }
23            Component::CurDir => {
24                // Skip current dir references
25            }
26            _ => {
27                components.push(component);
28            }
29        }
30    }
31
32    components
33        .iter()
34        .collect::<PathBuf>()
35        .to_string_lossy()
36        .to_string()
37}
38
39/// A single sample from the profile
40///
41/// Represents a point in time where the profiler captured the call stack.
42/// For CPU profiles, weight is the time delta since the last sample.
43/// For heap profiles, weight is the allocation size in bytes.
44#[derive(Debug, Clone, Serialize)]
45pub struct Sample {
46    /// Timestamp in microseconds (from profile start)
47    pub timestamp_us: u64,
48
49    /// The call stack at this sample
50    pub stack_id: StackId,
51
52    /// Weight of this sample:
53    /// - CPU profile: time delta in microseconds
54    /// - Heap profile: allocation size in bytes
55    pub weight: u64,
56}
57
58impl Sample {
59    /// Create a new sample
60    pub fn new(timestamp_us: u64, stack_id: StackId, weight: u64) -> Self {
61        Self {
62            timestamp_us,
63            stack_id,
64            weight,
65        }
66    }
67}
68
69/// The normalized intermediate representation of a profile
70///
71/// This is the common format that all analyses and outputs work with,
72/// regardless of whether the source was a CPU or heap profile.
73#[derive(Debug, Clone)]
74pub struct ProfileIR {
75    /// All unique frames in the profile
76    pub frames: Vec<super::Frame>,
77
78    /// All unique stacks in the profile
79    pub stacks: Vec<super::Stack>,
80
81    /// All samples in chronological order
82    pub samples: Vec<Sample>,
83
84    /// Profile type indicator
85    pub profile_type: ProfileType,
86
87    /// Total duration in microseconds (for CPU profiles)
88    pub duration_us: Option<u64>,
89
90    /// Source file name
91    pub source_file: Option<String>,
92
93    /// Number of frames resolved via sourcemaps
94    pub sourcemaps_resolved: usize,
95
96    /// Number of profiles merged (1 = single profile, >1 = merged)
97    pub profiles_merged: usize,
98}
99
100/// Type of profile
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum ProfileType {
103    Cpu,
104    Heap,
105}
106
107impl ProfileIR {
108    /// Create a new CPU profile IR
109    pub fn new_cpu(
110        frames: Vec<super::Frame>,
111        stacks: Vec<super::Stack>,
112        samples: Vec<Sample>,
113        duration_us: u64,
114        source_file: Option<String>,
115    ) -> Self {
116        Self {
117            frames,
118            stacks,
119            samples,
120            profile_type: ProfileType::Cpu,
121            duration_us: Some(duration_us),
122            source_file,
123            sourcemaps_resolved: 0,
124            profiles_merged: 1,
125        }
126    }
127
128    /// Create a new heap profile IR
129    pub fn new_heap(
130        frames: Vec<super::Frame>,
131        stacks: Vec<super::Stack>,
132        samples: Vec<Sample>,
133        source_file: Option<String>,
134    ) -> Self {
135        Self {
136            frames,
137            stacks,
138            samples,
139            profile_type: ProfileType::Heap,
140            duration_us: None,
141            source_file,
142            sourcemaps_resolved: 0,
143            profiles_merged: 1,
144        }
145    }
146
147    /// Get the total weight of all samples
148    pub fn total_weight(&self) -> u64 {
149        self.samples.iter().map(|s| s.weight).sum()
150    }
151
152    /// Get the number of samples
153    pub fn sample_count(&self) -> usize {
154        self.samples.len()
155    }
156
157    /// Get frame by ID
158    pub fn get_frame(&self, id: super::FrameId) -> Option<&super::Frame> {
159        self.frames.iter().find(|f| f.id == id)
160    }
161
162    /// Get stack by ID
163    pub fn get_stack(&self, id: StackId) -> Option<&super::Stack> {
164        self.stacks.iter().find(|s| s.id == id)
165    }
166
167    /// Resolve frame locations using sourcemaps.
168    ///
169    /// Returns the number of frames successfully resolved.
170    pub fn resolve_sourcemaps(&mut self, sourcemap_dirs: Vec<PathBuf>) -> usize {
171        if sourcemap_dirs.is_empty() {
172            return 0;
173        }
174
175        let mut resolver = SourceMapResolver::new(sourcemap_dirs.clone());
176        let mut resolved_count = 0;
177
178        // Use first sourcemap dir as base for resolving relative paths
179        // (sourcemap paths are relative to where the .map file is located)
180        let base_dir = sourcemap_dirs.first().cloned();
181
182        // Track resolved locations to detect problematic source maps
183        let mut location_counts: std::collections::HashMap<String, usize> =
184            std::collections::HashMap::new();
185
186        for frame in &mut self.frames {
187            // Skip frames without file/line info
188            let (Some(file), Some(line), Some(col)) = (&frame.file, frame.line, frame.col) else {
189                continue;
190            };
191
192            // Try to resolve via sourcemap
193            if let Some(resolved) = resolver.resolve(file, line, col) {
194                // Store original minified info
195                frame.minified_name = Some(frame.name.clone());
196                frame.minified_location = Some(frame.location());
197
198                // Update with resolved info
199                if let Some(name) = resolved.name {
200                    if !name.is_empty() {
201                        frame.name = name;
202                    }
203                }
204
205                // Normalize the resolved file path to absolute
206                let normalized_path = Self::normalize_sourcemap_path(&resolved.file, &base_dir);
207                frame.file = Some(normalized_path.clone());
208                frame.line = Some(resolved.line);
209                frame.col = Some(resolved.col);
210
211                // Track this location
212                let loc_key = format!("{}:{}", normalized_path, resolved.line);
213                *location_counts.entry(loc_key).or_insert(0) += 1;
214
215                // Re-classify category based on new path (e.g., node_modules -> Deps)
216                frame.category = Self::classify_category_from_path(&normalized_path);
217
218                resolved_count += 1;
219            }
220        }
221
222        // Warn if many frames resolve to the same location (indicates problematic source map)
223        if resolved_count > 10 {
224            for (loc, count) in &location_counts {
225                let pct = (*count as f64 / resolved_count as f64) * 100.0;
226                if pct > 50.0 && *count > 20 {
227                    eprintln!(
228                        "  ⚠️  Warning: {} frames ({:.0}%) resolved to same location: {}",
229                        count, pct, loc
230                    );
231                    eprintln!(
232                        "     This usually means the source map points to a bundled file, not original sources."
233                    );
234                    eprintln!(
235                        "     Try pointing --sourcemap-dir to a directory with maps to original .ts files."
236                    );
237                    break;
238                }
239            }
240        }
241
242        self.sourcemaps_resolved = resolved_count;
243        resolved_count
244    }
245
246    /// Normalize a sourcemap path to an absolute path.
247    fn normalize_sourcemap_path(path: &str, base_dir: &Option<PathBuf>) -> String {
248        // Strip common URL prefixes
249        let path = path
250            .strip_prefix("webpack://")
251            .or_else(|| path.strip_prefix("webpack:///"))
252            .or_else(|| path.strip_prefix("file://"))
253            .unwrap_or(path);
254
255        // Remove leading project name in webpack paths (e.g., "project/src/file.ts" -> "src/file.ts")
256        let path = if path.contains('/') && !path.starts_with('.') && !path.starts_with('/') {
257            // Check if first segment looks like a project name (no extension)
258            let first_segment = path.split('/').next().unwrap_or("");
259            if !first_segment.contains('.') {
260                path.split_once('/').map(|(_, rest)| rest).unwrap_or(path)
261            } else {
262                path
263            }
264        } else {
265            path
266        };
267
268        // If we have a base directory and path is relative, resolve it
269        if let Some(base) = base_dir {
270            let path_buf = PathBuf::from(path);
271            if path_buf.is_relative() {
272                let resolved = base.join(&path_buf);
273                // Canonicalize to resolve ../ and ./ components
274                if let Ok(canonical) = resolved.canonicalize() {
275                    return canonical.to_string_lossy().to_string();
276                }
277                // If canonicalize fails (file doesn't exist), just normalize manually
278                return normalize_path_components(&resolved);
279            }
280        }
281
282        path.to_string()
283    }
284
285    /// Re-classify frame category based on resolved path.
286    fn classify_category_from_path(path: &str) -> FrameCategory {
287        // node_modules -> Dependencies
288        if path.contains("node_modules") {
289            return FrameCategory::Deps;
290        }
291
292        // Node.js internals
293        if path.starts_with("node:") || path.contains("internal/") {
294            return FrameCategory::NodeInternal;
295        }
296
297        // Default to App
298        FrameCategory::App
299    }
300
301    /// Merge multiple profiles into one.
302    ///
303    /// Frames are deduplicated by (name, file, line, col).
304    /// Stacks are deduplicated by their frame sequence.
305    /// Samples from all profiles are combined.
306    /// Duration is summed across all profiles.
307    pub fn merge(profiles: Vec<Self>) -> Option<Self> {
308        if profiles.is_empty() {
309            return None;
310        }
311
312        if profiles.len() == 1 {
313            return profiles.into_iter().next();
314        }
315
316        let profile_type = profiles[0].profile_type;
317
318        // Map from (name, file, line, col) -> new FrameId
319        let mut frame_key_to_id: HashMap<
320            (String, Option<String>, Option<u32>, Option<u32>),
321            FrameId,
322        > = HashMap::new();
323        let mut merged_frames: Vec<super::Frame> = Vec::new();
324
325        // Map from frame sequence -> new StackId
326        let mut stack_key_to_id: HashMap<Vec<FrameId>, StackId> = HashMap::new();
327        let mut merged_stacks: Vec<super::Stack> = Vec::new();
328
329        let mut merged_samples: Vec<Sample> = Vec::new();
330        let mut total_duration: u64 = 0;
331        let mut total_sourcemaps_resolved: usize = 0;
332        let profiles_count = profiles.len();
333
334        for profile in profiles {
335            // Build mapping from old FrameId -> new FrameId for this profile
336            let mut frame_id_map: HashMap<FrameId, FrameId> = HashMap::new();
337
338            for frame in &profile.frames {
339                let key = (
340                    frame.name.clone(),
341                    frame.file.clone(),
342                    frame.line,
343                    frame.col,
344                );
345
346                let new_id = *frame_key_to_id.entry(key).or_insert_with(|| {
347                    let id = FrameId(merged_frames.len() as u32);
348                    let mut new_frame = frame.clone();
349                    new_frame.id = id;
350                    merged_frames.push(new_frame);
351                    id
352                });
353
354                frame_id_map.insert(frame.id, new_id);
355            }
356
357            // Build mapping from old StackId -> new StackId for this profile
358            let mut stack_id_map: HashMap<StackId, StackId> = HashMap::new();
359
360            for stack in &profile.stacks {
361                // Remap frame IDs in this stack
362                let remapped_frames: Vec<FrameId> = stack
363                    .frames
364                    .iter()
365                    .filter_map(|fid| frame_id_map.get(fid).copied())
366                    .collect();
367
368                let new_id = *stack_key_to_id
369                    .entry(remapped_frames.clone())
370                    .or_insert_with(|| {
371                        let id = StackId(merged_stacks.len() as u32);
372                        merged_stacks.push(super::Stack::new(id, remapped_frames));
373                        id
374                    });
375
376                stack_id_map.insert(stack.id, new_id);
377            }
378
379            // Remap samples
380            for sample in &profile.samples {
381                if let Some(&new_stack_id) = stack_id_map.get(&sample.stack_id) {
382                    merged_samples.push(Sample {
383                        timestamp_us: sample.timestamp_us,
384                        stack_id: new_stack_id,
385                        weight: sample.weight,
386                    });
387                }
388            }
389
390            if let Some(dur) = profile.duration_us {
391                total_duration += dur;
392            }
393            total_sourcemaps_resolved += profile.sourcemaps_resolved;
394        }
395
396        Some(Self {
397            frames: merged_frames,
398            stacks: merged_stacks,
399            samples: merged_samples,
400            profile_type,
401            duration_us: if total_duration > 0 {
402                Some(total_duration)
403            } else {
404                None
405            },
406            source_file: Some("(merged)".to_string()),
407            sourcemaps_resolved: total_sourcemaps_resolved,
408            profiles_merged: profiles_count,
409        })
410    }
411}