From 1b1ad7201cc6b5f32b66b0178cfdd3e99ec292a3 Mon Sep 17 00:00:00 2001 From: Miles Conn Date: Fri, 20 Feb 2026 15:17:51 -0800 Subject: [PATCH 1/2] Adds a field `crate_root_paths` that allows filtering for crates not in `.cargo/registry`. This field is itended to hold an array of paths where crates might live. This enables filtering for vendored crates or for rust builds not using cargo. Additionally, this adds a `skipped_path_patterns` options to `BackTraceFilter` that allows filtering of non rust dependencies like glibc, or test harnesses like gsuite. Note, both these changes require a semver bump. --- Cargo.lock | 26 --- rootcause-backtrace/Cargo.toml | 1 - rootcause-backtrace/src/lib.rs | 278 ++++++++++++++++++++++----------- 3 files changed, 184 insertions(+), 121 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0b49170..859dda6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -912,31 +912,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" - [[package]] name = "reqwest" version = "0.13.2" @@ -1017,7 +992,6 @@ name = "rootcause-backtrace" version = "0.12.1" dependencies = [ "backtrace", - "regex", "rootcause", "unicode-ident", ] diff --git a/rootcause-backtrace/Cargo.toml b/rootcause-backtrace/Cargo.toml index 0b99a32..c44a8a5 100644 --- a/rootcause-backtrace/Cargo.toml +++ b/rootcause-backtrace/Cargo.toml @@ -15,7 +15,6 @@ backtrace = { version = "0.3.76", default-features = false, features = [ "std", "cpp_demangle", ] } -regex = { version = "1.12.2", default-features = false } unicode-ident = "1.0.22" # Internal dependencies diff --git a/rootcause-backtrace/src/lib.rs b/rootcause-backtrace/src/lib.rs index 1a62d6f..45fb807 100644 --- a/rootcause-backtrace/src/lib.rs +++ b/rootcause-backtrace/src/lib.rs @@ -124,6 +124,8 @@ //! skipped_initial_crates: &["rootcause", "rootcause-backtrace"], // Skip frames from rootcause at start //! skipped_middle_crates: &["tokio"], // Skip tokio frames in middle //! skipped_final_crates: &["std"], // Skip std frames at end +//! skipped_path_patterns: &[], // Skip paths that contain this regex, useful for non rust code +//! crate_root_paths: &[".cargo/registry/"], // Path roots for crate identification //! max_entry_count: 15, // Limit to 15 frames //! show_full_path: false, // Show shortened paths //! }, @@ -131,7 +133,7 @@ //! }; //! ``` -use std::{borrow::Cow, fmt, panic::Location, sync::OnceLock}; +use std::{fmt, panic::Location, sync::OnceLock}; use backtrace::BytesOrWideString; use rootcause::{ @@ -206,8 +208,6 @@ pub struct Frame { pub struct FramePath { /// The raw file path from the debug information. pub raw_path: String, - /// The crate name if detected from the path. - pub crate_name: Option>, /// Common path prefix information for shortening display. pub split_path: Option, } @@ -220,8 +220,7 @@ pub struct FramePath { pub struct FramePrefix { /// The kind of prefix used to identify this prefix. /// - /// Examples: `"RUST_SRC"` for Rust standard library paths, - /// `"CARGO"` for Cargo registry crate paths, + /// Examples: `"CRATE"` for crate paths, /// `"ROOTCAUSE"` for rootcause library paths. pub prefix_kind: &'static str, /// The full prefix path that was removed from the original path. @@ -288,6 +287,94 @@ fn get_function_name(s: &str) -> &str { } } +/// Compares two crate names treating `-` and `_` as equivalent. +fn crate_name_matches(a: &str, b: &str) -> bool { + a.len() == b.len() && starts_with_normalized(a, b) +} + +/// Checks if a path component is a versioned crate directory (e.g. `tokio-1.37.0`) +/// and returns the crate name portion if valid. +fn extract_versioned_crate_name(component: &str) -> Option<&str> { + let bytes = component.as_bytes(); + for i in 0..bytes.len().saturating_sub(1) { + if bytes[i] == b'-' && bytes[i + 1].is_ascii_digit() { + let name = &component[..i]; + if !name.is_empty() + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + { + return Some(name); + } + return None; + } + } + None +} + +/// Extracts a crate name from a file path using configured root paths. +/// +/// Scans path components after a matched root for a versioned crate directory. +/// Falls back to the first component if no versioned directory is found. +fn crate_name_from_path(path: &str, roots: &[&str]) -> Option { + for root in roots { + let Some(idx) = path.find(root) else { + continue; + }; + let after = &path[idx + root.len()..]; + let mut first_component = None; + for component in after.split(&['/', '\\']) { + if component.is_empty() { + continue; + } + if let Some(name) = extract_versioned_crate_name(component) { + return Some(name.to_string()); + } + first_component.get_or_insert(component); + } + if let Some(component) = first_component { + return Some(component.to_string()); + } + } + None +} + +fn starts_with_normalized(a: &str, b: &str) -> bool { + a.bytes().zip(b.bytes()).all(crate_byte_is_equal) +} + +fn crate_byte_is_equal((x, y): (u8, u8)) -> bool { + x == y || (x == b'-' || x == b'_') && (y == b'-' || y == b'_') +} + +/// Finds the split point in a path where a crate directory starts. +/// +/// Searches backwards for a path component starting with `crate_name` +/// (using dash/underscore normalization). +fn find_crate_split(path: &str, crate_name: &str) -> Option { + let bytes = path.as_bytes(); + let mut i = bytes.len(); + while i > 0 { + i -= 1; + if (bytes[i] == b'/' || bytes[i] == b'\\') && is_crate_component(&path[i + 1..], crate_name) + { + return Some(i + 1); + } + } + if is_crate_component(path, crate_name) { + return Some(0); + } + None +} + +fn is_crate_component(component: &str, crate_name: &str) -> bool { + if !starts_with_normalized(component, crate_name) { + return false; + } + let after = component.as_bytes().get(crate_name.len()); + after.is_none() || matches!(after, Some(b'/' | b'\\' | b'-' | b'_' | b'.')) +} + impl AttachmentHandler for BacktraceHandler { fn display(value: &Backtrace, f: &mut fmt::Formatter<'_>) -> fmt::Result { const MAX_UNWRAPPED_SYM_LENGTH: usize = 25; @@ -406,6 +493,8 @@ impl AttachmentHandler for BacktraceHandl /// skipped_initial_crates: &[], /// skipped_middle_crates: &[], /// skipped_final_crates: &[], +/// skipped_path_patterns: &[], +/// crate_root_paths: &[], /// max_entry_count: 30, /// show_full_path: true, /// }, @@ -454,6 +543,10 @@ pub struct BacktraceCollector { /// skipped_middle_crates: &["tokio", "hyper", "tower"], /// // Hide runtime frames at the end /// skipped_final_crates: &["std", "tokio"], +/// // Hide frames whose file path matches any of these patterns +/// skipped_path_patterns: &["rustc"], +/// // Identify crates by path for filtering +/// crate_root_paths: &[".cargo/registry/"], /// // Show only the most relevant 10 frames /// max_entry_count: 10, /// // Show shortened paths @@ -471,6 +564,14 @@ pub struct BacktraceFilter { /// Set of crate names whose frames should be hidden when they appear /// at the end of a backtrace. pub skipped_final_crates: &'static [&'static str], + /// Substring patterns matched against frame file paths. Frames whose + /// path contains any of these patterns are unconditionally omitted. + pub skipped_path_patterns: &'static [&'static str], + /// Path substrings under which crate directories can be found. + /// When a frame's path contains one of these substrings, the next path + /// component is used as the crate name (stripping version suffixes like + /// `-1.37.0`). + pub crate_root_paths: &'static [&'static str], /// Maximum number of entries to include in the backtrace. pub max_entry_count: usize, /// Whether to show full file paths in the backtrace frames. @@ -490,6 +591,8 @@ impl BacktraceFilter { ], skipped_middle_crates: &["std", "core", "alloc", "tokio"], skipped_final_crates: &["std", "core", "alloc", "tokio"], + skipped_path_patterns: &["rustc"], + crate_root_paths: &[".cargo/registry/"], max_entry_count: 20, show_full_path: false, }; @@ -575,6 +678,8 @@ impl BacktraceCollector { skipped_initial_crates: &[], skipped_middle_crates: &[], skipped_final_crates: &[], + skipped_path_patterns: &[], + crate_root_paths: &[], max_entry_count: usize::MAX, show_full_path: env_options.show_full_path, } @@ -681,11 +786,27 @@ impl Backtrace { return; } - let frame_path = FramePath::new(filename_raw); + let path_str = filename_raw.to_string(); + + if !filter.skipped_path_patterns.is_empty() + && filter + .skipped_path_patterns + .iter() + .any(|pattern| path_str.contains(pattern)) + { + total_omitted_frames += 1; + return; + } + + let sym_demangled = format!("{sym:#}"); + let effective_crate = crate_name_from_path(&path_str, filter.crate_root_paths); if initial_filtering { - if let Some(cur_crate_name) = &frame_path.crate_name - && filter.skipped_initial_crates.contains(&&**cur_crate_name) + if let Some(cur_crate) = effective_crate.as_deref() + && filter + .skipped_initial_crates + .iter() + .any(|name| crate_name_matches(name, cur_crate)) { total_omitted_frames += 1; return; @@ -694,9 +815,9 @@ impl Backtrace { } } - if let Some(cur_crate_name) = &frame_path.crate_name + if let Some(cur_crate) = effective_crate.as_deref() && let Some(currently_omitted_crate_name) = ¤tly_omitted_crate_name - && cur_crate_name == currently_omitted_crate_name + && crate_name_matches(currently_omitted_crate_name, cur_crate) { delayed_omitted_frame = None; currently_omitted_frames += 1; @@ -716,17 +837,19 @@ impl Backtrace { currently_omitted_frames = 0; } - if let Some(cur_crate_name) = &frame_path.crate_name + let frame_path = FramePath::new(filename_raw, effective_crate.as_deref()); + + if let Some(cur_crate) = effective_crate.as_deref() && let Some(skipped_crate) = filter .skipped_middle_crates .iter() - .find(|&crate_name| crate_name == cur_crate_name) + .find(|&name| crate_name_matches(name, cur_crate)) { currently_omitted_crate_name = Some(skipped_crate); currently_omitted_frames = 1; total_omitted_frames += 1; delayed_omitted_frame = Some(Frame { - sym_demangled: format!("{sym:#}"), + sym_demangled, frame_path: Some(frame_path), lineno: symbol.lineno(), }); @@ -734,7 +857,7 @@ impl Backtrace { } entries.push(BacktraceEntry::Frame(Frame { - sym_demangled: format!("{sym:#}"), + sym_demangled, frame_path: Some(frame_path), lineno: symbol.lineno(), })); @@ -758,18 +881,22 @@ impl Backtrace { match last { BacktraceEntry::Frame(frame) => { let mut skip = false; - if let Some(frame_path) = &frame.frame_path - && let Some(crate_name) = &frame_path.crate_name - && filter.skipped_final_crates.contains(&&**crate_name) - { - skip = true; - } else if frame.sym_demangled == "__libc_start_call_main" - || frame.sym_demangled == "__libc_start_main_impl" + let effective_crate = frame + .frame_path + .as_ref() + .and_then(|fp| crate_name_from_path(&fp.raw_path, filter.crate_root_paths)); + if let Some(cur_crate) = effective_crate.as_deref() + && filter + .skipped_final_crates + .iter() + .any(|name| crate_name_matches(name, cur_crate)) { skip = true; } else if let Some(frame_path) = &frame.frame_path - && frame.sym_demangled == "_start" - && frame_path.raw_path.contains("zig/libc/glibc") + && filter + .skipped_path_patterns + .iter() + .any(|pattern| frame_path.raw_path.contains(pattern)) { skip = true; } @@ -807,105 +934,66 @@ impl Backtrace { } impl FramePath { - fn new(path: BytesOrWideString<'_>) -> Self { - static REGEXES: OnceLock<[regex::Regex; 2]> = OnceLock::new(); - let [std_regex, registry_regex] = REGEXES.get_or_init(|| { - [ - // Matches Rust standard library paths: - // - /lib/rustlib/src/rust/library/{std|core|alloc}/src/... - // - /rustc/{40-char-hash}/library/{std|core|alloc}/src/... - regex::Regex::new( - r"(?:/lib/rustlib/src/rust|^/rustc/[0-9a-f]{40})/library/(std|core|alloc)/src/.*$", - ) - .expect("built-in regex pattern for std library paths should be valid"), - // Matches Cargo registry paths: - // - /.cargo/registry/src/{index}-{16-char-hash}/{crate}-{version}/src/... - regex::Regex::new( - r"/\.cargo/registry/src/[^/]+-[0-9a-f]{16}/([^./]+)-[0-9]+\.[^/]*/src/.*$", - ) - .expect("built-in regex pattern for cargo registry paths should be valid"), - ] - }); - + fn new(path: BytesOrWideString<'_>, crate_name: Option<&str>) -> Self { let path_str = path.to_string(); + let raw_path = path.to_str_lossy().into_owned(); - if let Some(captures) = std_regex.captures(&path_str) { - let raw_path = path.to_str_lossy().into_owned(); - let crate_capture = captures - .get(1) - .expect("regex capture group 1 should exist for std library paths"); - let split = crate_capture.start(); - let (prefix, suffix) = (&path_str[..split - 1], &path_str[split..]); - - Self { - raw_path, - split_path: Some(FramePrefix { - prefix_kind: "RUST_SRC", - prefix: prefix.to_string(), - suffix: suffix.to_string(), - }), - crate_name: Some(crate_capture.as_str().to_string().into()), - } - } else if let Some(captures) = registry_regex.captures(&path_str) { - let raw_path = path.to_str_lossy().into_owned(); - let crate_capture = captures - .get(1) - .expect("regex capture group 1 should exist for cargo registry paths"); - let split = crate_capture.start(); - let (prefix, suffix) = (&path_str[..split - 1], &path_str[split..]); - - Self { - raw_path, - crate_name: Some(crate_capture.as_str().to_string().into()), - split_path: Some(FramePrefix { - prefix_kind: "CARGO", - prefix: prefix.to_string(), - suffix: suffix.to_string(), - }), - } - } else if let Some((rootcause_matcher_prefix, rootcause_splitter_prefix_len)) = - ROOTCAUSE_MATCHER + if let Some((rootcause_matcher_prefix, rootcause_splitter_prefix_len)) = ROOTCAUSE_MATCHER && path_str.starts_with(rootcause_matcher_prefix) { - let raw_path = path.to_str_lossy().into_owned(); let (prefix, suffix) = ( &path_str[..rootcause_splitter_prefix_len], &path_str[rootcause_splitter_prefix_len + 1..], ); - Self { + return Self { raw_path, split_path: Some(FramePrefix { prefix_kind: "ROOTCAUSE", prefix: prefix.to_string(), suffix: suffix.to_string(), }), - crate_name: Some(Cow::Borrowed("rootcause")), - } - } else if let Some((rootcause_matcher_prefix, rootcause_splitter_prefix_len)) = + }; + } + + if let Some((rootcause_matcher_prefix, rootcause_splitter_prefix_len)) = ROOTCAUSE_BACKTRACE_MATCHER && path_str.starts_with(rootcause_matcher_prefix) { - let raw_path = path.to_str_lossy().into_owned(); let (prefix, suffix) = ( &path_str[..rootcause_splitter_prefix_len], &path_str[rootcause_splitter_prefix_len + 1..], ); - Self { + return Self { raw_path, split_path: Some(FramePrefix { prefix_kind: "ROOTCAUSE", prefix: prefix.to_string(), suffix: suffix.to_string(), }), - crate_name: Some(Cow::Borrowed("rootcause-backtrace")), - } - } else { - let raw_path = path.to_str_lossy().into_owned(); - Self { + }; + } + + if let Some(crate_name) = crate_name + && let Some(split) = find_crate_split(&path_str, crate_name) + { + let (prefix, suffix) = if split > 0 { + (&path_str[..split - 1], &path_str[split..]) + } else { + ("", &path_str[..]) + }; + return Self { raw_path, - crate_name: None, - split_path: None, - } + split_path: Some(FramePrefix { + prefix_kind: "CRATE", + prefix: prefix.to_string(), + suffix: suffix.to_string(), + }), + }; + } + + Self { + raw_path, + split_path: None, } } } @@ -955,6 +1043,8 @@ impl FramePath { /// skipped_initial_crates: &[], /// skipped_middle_crates: &[], /// skipped_final_crates: &[], +/// skipped_path_patterns: &[], +/// crate_root_paths: &[], /// max_entry_count: 50, /// show_full_path: true, /// }; From a95c792341dc25a47f51fbb9f4e1fcc6e052316e Mon Sep 17 00:00:00 2001 From: Miles Conn Date: Wed, 4 Mar 2026 11:44:03 -0800 Subject: [PATCH 2/2] RUST_BACKTRACE=full now ignores all backtrace filtering. This behaviour is more in line with the flavor text. --- rootcause-backtrace/src/lib.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/rootcause-backtrace/src/lib.rs b/rootcause-backtrace/src/lib.rs index 45fb807..ee30ae8 100644 --- a/rootcause-backtrace/src/lib.rs +++ b/rootcause-backtrace/src/lib.rs @@ -765,7 +765,25 @@ const ROOTCAUSE_MATCHER: Option<(&str, usize)> = impl Backtrace { /// Captures the current stack backtrace, applying optional filtering. + /// + /// When `RUST_BACKTRACE=full` is set, all filtering is bypassed regardless + /// of the provided filter configuration. pub fn capture(filter: &BacktraceFilter) -> Option { + let unfiltered; + let filter = if RootcauseEnvOptions::get().rust_backtrace_full { + unfiltered = BacktraceFilter { + skipped_initial_crates: &[], + skipped_middle_crates: &[], + skipped_final_crates: &[], + max_entry_count: usize::MAX, + show_full_path: filter.show_full_path, + skipped_path_patterns: &[], + crate_root_paths: &[], + }; + &unfiltered + } else { + filter + }; let mut initial_filtering = !filter.skipped_initial_crates.is_empty(); let mut entries: Vec = Vec::new(); let mut total_omitted_frames = 0;