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}