From bdae57d801aa2a6136d0b18792474ffbc63fb7c9 Mon Sep 17 00:00:00 2001 From: Alexandr Mansurov Date: Fri, 20 Feb 2026 23:29:21 +0100 Subject: [PATCH] Search for exceptions --- src/main.rs | 22 ++++++++ src/search.rs | 146 ++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 164 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index 831b26f..53b725d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,6 +32,8 @@ enum Command { Signature(SignatureArgs), /// Search log file for lines matching a query and print timestamp + message Search(SearchArgs), + /// Search for Exception lines with expand, filtered by app signature + SearchExceptions(SearchExceptionsArgs), } #[derive(Parser, Debug)] @@ -93,6 +95,21 @@ struct SearchArgs { threads: usize, } +#[derive(Parser, Debug)] +struct SearchExceptionsArgs { + /// Log file to search + #[arg(long)] + file: PathBuf, + + /// Filter results to sessions with signature: only (repeatable) + #[arg(long, required = true)] + app: Vec, + + /// Number of parallel threads (0 = use all available cores, 1 = sequential) + #[arg(long, default_value = "0")] + threads: usize, +} + fn parse_date(s: &str) -> Result { NaiveDate::parse_from_str(s, "%Y/%m/%d") .map_err(|e| anyhow!("Invalid date format '{}': {}. Expected YYYY/mm/dd", s, e)) @@ -110,6 +127,11 @@ fn main() -> Result<()> { search_args.expand, search_args.threads, ), + Command::SearchExceptions(args) => search::run_search_exceptions( + args.file.to_str().unwrap(), + &args.app, + args.threads, + ), } } diff --git a/src/search.rs b/src/search.rs index bf538ba..83716e7 100644 --- a/src/search.rs +++ b/src/search.rs @@ -23,6 +23,11 @@ static SESSION_ID_RE: LazyLock = static SESSION_DESTROYED_RE: LazyLock = LazyLock::new(|| Regex::new(r"sessionDestroyed\b.*?\bsid=([^,\s]+)").unwrap()); +/// Extracts the app name from any signature line format. +/// Matches both `msg="signature:APP/..."` and `msg="MOBILE_CLIENT_LOG: signature:APP/..."`. +static SIGNATURE_APP_RE: LazyLock = + LazyLock::new(|| Regex::new(r"signature:([^/\s]+)/").unwrap()); + /// Matches changeSessionId messages and captures the long-form new and old session IDs. /// Example: changeSessionId: newSessionId: sDF080BBD / DF080BBD...node011 replaces oldSessionId: sF9EE9D52 / F9EE9D52...node011 static CHANGE_SESSION_RE: LazyLock = LazyLock::new(|| { @@ -243,6 +248,10 @@ struct Pass1Result { seed_correlation_ids: HashSet, change_session_map: HashMap, sessions_with_signature: HashSet, + /// Maps normalized session ID to app name from its signature line + session_app_map: HashMap, + /// Maps seed correlation ID to the session ID from the same line (for app filtering) + seed_cid_sessions: HashMap, line_count: u64, } @@ -253,6 +262,8 @@ impl Pass1Result { self.change_session_map.extend(other.change_session_map); self.sessions_with_signature .extend(other.sessions_with_signature); + self.session_app_map.extend(other.session_app_map); + self.seed_cid_sessions.extend(other.seed_cid_sessions); self.line_count += other.line_count; self } @@ -264,10 +275,16 @@ fn process_line_pass1(trimmed: &str, query: &str, result: &mut Pass1Result) { .and_then(|c| c.get(1)) .map(|m| normalize_session_id(m.as_str())); - if trimmed.contains(r#"msg="signature:"#) - && let Some(s) = sid - { - result.sessions_with_signature.insert(s.to_string()); + // Detect signature lines using broad regex (matches both msg="signature:APP/..." + // and msg="MOBILE_CLIENT_LOG: signature:APP/...") + if let Some(sig_caps) = SIGNATURE_APP_RE.captures(trimmed) { + let app = sig_caps.get(1).unwrap().as_str(); + if let Some(s) = sid { + result.sessions_with_signature.insert(s.to_string()); + result + .session_app_map + .insert(s.to_string(), app.to_string()); + } } if trimmed.contains("changeSessionId:") @@ -283,6 +300,15 @@ fn process_line_pass1(trimmed: &str, query: &str, result: &mut Pass1Result) { && s != "noSession" { result.seed_session_ids.insert(s.to_string()); + // Track which correlation IDs belong to which sessions (for app filtering) + if let Some(cid) = CORRELATION_ID_RE + .captures(trimmed) + .and_then(|c| c.get(1)) + { + result + .seed_cid_sessions + .insert(cid.as_str().to_string(), s.to_string()); + } } if let Some(cid) = CORRELATION_ID_RE .captures(trimmed) @@ -550,6 +576,118 @@ fn run_pass2( Ok(total) } +// --- search_exceptions --- + +/// Filter expanded session IDs to only those in chains containing a matching-app signature. +/// Builds a reverse change_session_map (old → [new]) and propagates forward from matching roots. +fn filter_expanded_by_app( + expanded_sids: &HashSet, + change_map: &HashMap, + session_app_map: &HashMap, + app_filters: &[String], +) -> HashSet { + // Build reverse map: old → [new1, new2, ...] + let mut reverse_map: HashMap<&str, Vec<&str>> = HashMap::new(); + for (new_sid, old_sid) in change_map { + reverse_map + .entry(old_sid.as_str()) + .or_default() + .push(new_sid.as_str()); + } + + // Find all sessions with any matching app + let matching_roots: Vec<&str> = session_app_map + .iter() + .filter(|(_, app)| app_filters.iter().any(|f| f == app.as_str())) + .map(|(sid, _)| sid.as_str()) + .collect(); + + // Propagate forward from matching roots through the reverse map + let mut matching_sessions: HashSet = HashSet::new(); + let mut work_queue: Vec<&str> = matching_roots; + while let Some(current) = work_queue.pop() { + if !matching_sessions.insert(current.to_string()) { + continue; + } + if let Some(nexts) = reverse_map.get(current) { + work_queue.extend(nexts.iter().copied()); + } + } + + // Intersect with expanded_sids + expanded_sids + .intersection(&matching_sessions) + .cloned() + .collect() +} + +pub fn run_search_exceptions(file_path: &str, app_filters: &[String], threads: usize) -> Result<()> { + let pool = build_thread_pool(threads)?; + let query = "Exception"; + let num_threads = pool.current_num_threads(); + let use_parallel = can_parallelize(file_path, num_threads); + + // Pass 1: collect metadata + let pass1 = run_pass1(file_path, query, use_parallel, &pool)?; + + // Expand seeds + let (expanded_sids, expanded_cids) = expand_seeds( + &pass1.seed_session_ids, + &pass1.seed_correlation_ids, + &pass1.change_session_map, + &pass1.sessions_with_signature, + ); + + // Filter by app + let filtered_sids = filter_expanded_by_app( + &expanded_sids, + &pass1.change_session_map, + &pass1.session_app_map, + app_filters, + ); + + // Filter correlation IDs: keep only those whose seed line's session is in the filtered set + let filtered_cids: HashSet = expanded_cids + .iter() + .filter(|cid| { + pass1 + .seed_cid_sessions + .get(cid.as_str()) + .is_some_and(|sid| filtered_sids.contains(sid)) + }) + .cloned() + .collect(); + + if filtered_sids.is_empty() && filtered_cids.is_empty() { + eprintln!( + "0 matching lines found (no sessions matching apps {:?})", + app_filters + ); + return Ok(()); + } + + eprintln!( + "Expanding: {} session IDs (filtered from {}), {} correlation IDs (filtered from {})", + filtered_sids.len(), + expanded_sids.len(), + filtered_cids.len(), + expanded_cids.len() + ); + + // Pass 2: filter and print + let match_count = run_pass2( + file_path, + query, + &filtered_sids, + &filtered_cids, + use_parallel, + &pool, + )?; + + eprintln!("{} lines output", match_count); + Ok(()) +} + #[cfg(test)] mod tests { use super::*;