Skip to main content

stackpulse/
profile.rs

1use std::rc::Rc;
2
3use bitflags::bitflags;
4
5/// High-level frame category.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7#[non_exhaustive]
8pub enum FrameKind {
9    /// Python frame.
10    Python,
11    /// Native user-space frame.
12    Native,
13    /// Kernel frame.
14    Kernel,
15    /// Frame that could not be classified.
16    Unknown,
17}
18
19/// Where a symbol name came from.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21#[non_exhaustive]
22pub enum SymbolOrigin {
23    /// File-backed symbol information.
24    Elf,
25    /// Python perf-map entry.
26    PerfMap,
27    /// Kernel symbol table.
28    KernelSymbols,
29    /// Address-only fallback.
30    AddressOnly,
31}
32
33bitflags! {
34    /// Per-frame classification flags attached to every [`ResolvedFrame`].
35    ///
36    /// Flags are additive. Consumers commonly use these to hide
37    /// implementation-detail frames in default views (see
38    /// [`Self::HIDDEN_DEFAULT`]).
39    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40    pub struct FrameFlags: u32 {
41        /// Frame is from the Python runtime binary or `libpython`.
42        const PYTHON_RUNTIME = 1 << 0;
43        /// Frame should be hidden from default flame-graph / report views.
44        const HIDDEN_DEFAULT = 1 << 2;
45        /// Frame came from a JIT-emitted code region (perf-map entry).
46        const JIT = 1 << 3;
47        /// Sentinel frame marking where native unwinding stopped because the
48        /// captured stack bytes were exhausted (`stack_size` too small for
49        /// the full stack), not a real (or failed) address resolution.
50        const TRUNCATED_STACK = 1 << 4;
51    }
52}
53
54/// Optional source-position information attached to a [`PythonFrame`].
55///
56/// A value of `-1` for any field means "unknown"; this matches the CPython
57/// convention for missing position attributes on code objects.
58#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
59pub struct LocationInfo {
60    /// 1-based starting line number (`-1` if unknown).
61    pub lineno: i32,
62    /// 1-based ending line number (`-1` if unknown).
63    pub end_lineno: i32,
64    /// 0-based starting column offset in bytes (`-1` if unknown).
65    pub column: i32,
66    /// 0-based ending column offset in bytes (`-1` if unknown).
67    pub end_column: i32,
68}
69
70/// A resolved Python frame.
71///
72/// Produced from a CPython perf-map entry (with `PYTHONPERFSUPPORT=1`) plus
73/// any inlined source-position info CPython provides. `Rc<str>` is used for
74/// the file and function strings so identical entries from repeated samples
75/// share one allocation across a profile.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct PythonFrame {
78    /// Source file as recorded by CPython. May be absolute, relative, or a
79    /// pseudo-path such as `<frozen importlib._bootstrap>`.
80    pub file_name: Rc<str>,
81    /// Source position information for the frame (line/column ranges).
82    pub location: LocationInfo,
83    /// Resolved function or method name.
84    pub func_name: Rc<str>,
85    /// Last executed bytecode opcode, if available.
86    pub opcode: Option<u8>,
87    /// Whether this frame is the entry point of a Python call (top of an
88    /// eval-loop activation, not an inlined or continuation frame).
89    pub is_entry: bool,
90    /// Byte offset into [`Self::file_name`] where the basename begins;
91    /// use [`Self::basename`] to read it.
92    pub basename_start: usize,
93}
94
95impl PythonFrame {
96    /// Construct a resolved Python frame, precomputing the basename offset.
97    #[must_use]
98    pub fn new(
99        file_name: &str,
100        location: LocationInfo,
101        func_name: &str,
102        opcode: Option<u8>,
103        is_entry: bool,
104    ) -> Self {
105        let basename_start = self::basename_start(file_name);
106        Self {
107            file_name: file_name.into(),
108            location,
109            func_name: func_name.into(),
110            opcode,
111            is_entry,
112            basename_start,
113        }
114    }
115
116    /// Final path component of [`Self::file_name`] (filename only).
117    #[inline]
118    #[must_use]
119    pub fn basename(&self) -> &str {
120        &self.file_name[self.basename_start..]
121    }
122}
123
124/// Optional source-position info attached to a [`NativeSymbol`].
125///
126/// All fields are `Option` because DWARF, debuginfod, and address-only
127/// fallbacks each provide different subsets. Callers should treat any missing
128/// field as "unknown" rather than "zero".
129#[derive(Debug, Clone, Default, PartialEq, Eq)]
130pub struct SourceLocation {
131    /// Source file path (absolute or compiler-relative).
132    pub file: Option<Rc<str>>,
133    /// 1-based line number of the sampled instruction.
134    pub line: Option<u32>,
135    /// 1-based column number of the sampled instruction.
136    pub column: Option<u32>,
137    /// 1-based line where the enclosing function starts.
138    pub function_start_line: Option<u32>,
139    /// 1-based column where the enclosing function starts.
140    pub function_start_column: Option<u32>,
141}
142
143/// A resolved native or kernel symbol.
144///
145/// One [`NativeFrame`] may resolve to multiple `NativeSymbol`s when inline
146/// frames are expanded; the innermost callee is listed first and
147/// [`Self::inline_depth`] grows outward.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct NativeSymbol {
150    /// Demangled symbol name (function/method).
151    pub name: Rc<str>,
152    /// Source file path, if debug info or perf-map metadata provided one.
153    pub file: Option<Rc<str>>,
154    /// 1-based source line of the sampled instruction.
155    pub line: Option<u32>,
156    /// 1-based source column of the sampled instruction.
157    pub column: Option<u32>,
158    /// 1-based line where the enclosing function starts.
159    pub function_start_line: Option<u32>,
160    /// 1-based column where the enclosing function starts.
161    pub function_start_column: Option<u32>,
162    /// On-disk path of the owning module (binary or shared library).
163    pub module: Rc<str>,
164    /// Byte offset into [`Self::module`] where the basename starts.
165    pub module_basename_start: usize,
166    /// Byte offset of the instruction within its enclosing function.
167    ///
168    /// `0` for fallback pseudo-symbols whose [`Self::name`] already embeds an
169    /// address (`module+0x...`, `[kernel]+0x...`). For inline expansions the
170    /// offset is relative to the outermost function's start.
171    pub offset: u64,
172    /// Nesting depth for inline expansions: `0` is the outermost enclosing
173    /// function, higher values are deeper inlined frames (the highest being
174    /// the innermost, sampled expansion).
175    pub inline_depth: u16,
176    /// Whether this symbol is the CPython bytecode evaluation loop.
177    pub is_eval_frame: bool,
178    /// Whether default views should hide this symbol (matches
179    /// [`FrameFlags::HIDDEN_DEFAULT`] semantics).
180    pub should_ignore: bool,
181}
182
183impl NativeSymbol {
184    /// Build a [`NativeSymbol`] for the innermost (non-inline) frame,
185    /// precomputing the module basename offset.
186    #[must_use]
187    pub fn new(
188        name: impl Into<Rc<str>>,
189        source: SourceLocation,
190        module: impl Into<Rc<str>>,
191        offset: u64,
192        is_eval_frame: bool,
193        should_ignore: bool,
194    ) -> Self {
195        let module = module.into();
196        let module_basename_start = basename_start(&module);
197        Self {
198            name: name.into(),
199            file: source.file,
200            line: source.line,
201            column: source.column,
202            function_start_line: source.function_start_line,
203            function_start_column: source.function_start_column,
204            module,
205            module_basename_start,
206            offset,
207            inline_depth: 0,
208            is_eval_frame,
209            should_ignore,
210        }
211    }
212}
213
214/// A resolved native, kernel, or address-only frame.
215///
216/// Carries the raw program counter and stack pointer from the sample plus
217/// whatever symbol metadata was recovered (or `None` when address-only).
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct NativeFrame {
220    /// Absolute program counter sampled from the target.
221    pub pc: u64,
222    /// Stack pointer at the time of the sample (`0` if not recorded).
223    pub sp: u64,
224    /// Resolved symbol, if symbolization succeeded.
225    pub symbol: Option<NativeSymbol>,
226    /// Whether the owning module is the Python runtime
227    /// (see [`is_python_module`](crate::is_python_module)).
228    pub is_python_runtime: bool,
229    /// High-level category: native, kernel, or unknown.
230    pub kind: FrameKind,
231    /// Where the symbol info came from (ELF, perf-map, kallsyms, address-only).
232    pub origin: SymbolOrigin,
233    /// Classification flags shared with [`PythonFrame`] consumers.
234    pub flags: FrameFlags,
235}
236
237impl NativeFrame {
238    /// Build an address-only [`NativeFrame`] for an IP that could not be
239    /// symbolized. `kind` is set to [`FrameKind::Unknown`] and `origin` to
240    /// [`SymbolOrigin::AddressOnly`].
241    #[must_use]
242    pub fn from_address(pc: u64) -> Self {
243        Self {
244            pc,
245            sp: 0,
246            symbol: None,
247            is_python_runtime: false,
248            kind: FrameKind::Unknown,
249            origin: SymbolOrigin::AddressOnly,
250            flags: FrameFlags::empty(),
251        }
252    }
253
254    /// Sentinel resolved frame for a truncated-stack marker (see
255    /// [`crate::FrameRecord::truncated_stack_marker`]): the unwinder ran out
256    /// of captured stack bytes before reaching the root. Distinguishable from
257    /// a failed resolve via [`FrameFlags::TRUNCATED_STACK`].
258    #[must_use]
259    pub fn truncated_stack_marker() -> Self {
260        Self {
261            pc: 0,
262            sp: 0,
263            symbol: Some(NativeSymbol::new(
264                "<stack truncated>",
265                SourceLocation::default(),
266                "",
267                0,
268                false,
269                false,
270            )),
271            is_python_runtime: false,
272            kind: FrameKind::Unknown,
273            origin: SymbolOrigin::AddressOnly,
274            flags: FrameFlags::TRUNCATED_STACK,
275        }
276    }
277
278    /// Display name for the frame: the resolved symbol name, or the
279    /// hex-formatted `pc` (`<0xCAFEBABE>`) when no symbol was recovered.
280    #[must_use]
281    pub fn func_name(&self) -> String {
282        self.symbol
283            .as_ref()
284            .map_or_else(|| format!("<0x{:x}>", self.pc), |s| s.name.to_string())
285    }
286}
287
288/// A resolved frame from a profile.
289#[derive(Debug, Clone, PartialEq, Eq)]
290pub enum ResolvedFrame {
291    /// Python frame.
292    Python(PythonFrame),
293    /// Native, kernel, or address-only frame.
294    Native(NativeFrame),
295}
296
297impl ResolvedFrame {
298    /// Display name across both variants: Python function name or the
299    /// native frame's [`NativeFrame::func_name`].
300    #[must_use]
301    pub fn func_name(&self) -> String {
302        match self {
303            Self::Python(frame) => frame.func_name.to_string(),
304            Self::Native(frame) => frame.func_name(),
305        }
306    }
307}
308
309/// Byte offset of the basename within `path`.
310///
311/// Returns the index of the first character after the last `/`, or `0` if
312/// `path` has no separators. UTF-8 safe because `/` cannot appear inside a
313/// multi-byte sequence.
314#[inline]
315#[must_use]
316pub fn basename_start(path: &str) -> usize {
317    memchr::memrchr(b'/', path.as_bytes()).map_or(0, |i| i + 1)
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn python_frame_basename_handles_long_ascii_path() {
326        let path = format!("{}/leaf.py", "a".repeat(70_000));
327        let frame = PythonFrame::new(&path, LocationInfo::default(), "f", None, false);
328
329        assert_eq!(frame.basename_start, path.rfind('/').unwrap() + 1);
330        assert_eq!(frame.basename(), "leaf.py");
331    }
332
333    #[test]
334    fn python_frame_basename_handles_long_utf8_path() {
335        let path = format!("{}é/leaf.py", "a".repeat(65_534));
336        let frame = PythonFrame::new(&path, LocationInfo::default(), "f", None, false);
337
338        assert_eq!(frame.basename_start, path.rfind('/').unwrap() + 1);
339        assert_eq!(frame.basename(), "leaf.py");
340    }
341
342    #[test]
343    fn basename_start_reports_offsets_above_u16_max() {
344        let path = format!("{}/leaf.py", "a".repeat(70_000));
345
346        assert_eq!(basename_start(&path), path.rfind('/').unwrap() + 1);
347    }
348}