Search for exceptions

This commit is contained in:
Alexandr Mansurov
2026-02-20 23:29:21 +01:00
parent 8620359c79
commit bdae57d801
2 changed files with 164 additions and 4 deletions

View File

@@ -32,6 +32,8 @@ enum Command {
Signature(SignatureArgs), Signature(SignatureArgs),
/// Search log file for lines matching a query and print timestamp + message /// Search log file for lines matching a query and print timestamp + message
Search(SearchArgs), Search(SearchArgs),
/// Search for Exception lines with expand, filtered by app signature
SearchExceptions(SearchExceptionsArgs),
} }
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@@ -93,6 +95,21 @@ struct SearchArgs {
threads: usize, threads: usize,
} }
#[derive(Parser, Debug)]
struct SearchExceptionsArgs {
/// Log file to search
#[arg(long)]
file: PathBuf,
/// Filter results to sessions with signature:<APP> only (repeatable)
#[arg(long, required = true)]
app: Vec<String>,
/// 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> { fn parse_date(s: &str) -> Result<NaiveDate> {
NaiveDate::parse_from_str(s, "%Y/%m/%d") NaiveDate::parse_from_str(s, "%Y/%m/%d")
.map_err(|e| anyhow!("Invalid date format '{}': {}. Expected YYYY/mm/dd", s, e)) .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.expand,
search_args.threads, search_args.threads,
), ),
Command::SearchExceptions(args) => search::run_search_exceptions(
args.file.to_str().unwrap(),
&args.app,
args.threads,
),
} }
} }

View File

@@ -23,6 +23,11 @@ static SESSION_ID_RE: LazyLock<Regex> =
static SESSION_DESTROYED_RE: LazyLock<Regex> = static SESSION_DESTROYED_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"sessionDestroyed\b.*?\bsid=([^,\s]+)").unwrap()); 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<Regex> =
LazyLock::new(|| Regex::new(r"signature:([^/\s]+)/").unwrap());
/// Matches changeSessionId messages and captures the long-form new and old session IDs. /// Matches changeSessionId messages and captures the long-form new and old session IDs.
/// Example: changeSessionId: newSessionId: sDF080BBD / DF080BBD...node011 replaces oldSessionId: sF9EE9D52 / F9EE9D52...node011 /// Example: changeSessionId: newSessionId: sDF080BBD / DF080BBD...node011 replaces oldSessionId: sF9EE9D52 / F9EE9D52...node011
static CHANGE_SESSION_RE: LazyLock<Regex> = LazyLock::new(|| { static CHANGE_SESSION_RE: LazyLock<Regex> = LazyLock::new(|| {
@@ -243,6 +248,10 @@ struct Pass1Result {
seed_correlation_ids: HashSet<String>, seed_correlation_ids: HashSet<String>,
change_session_map: HashMap<String, String>, change_session_map: HashMap<String, String>,
sessions_with_signature: HashSet<String>, sessions_with_signature: HashSet<String>,
/// Maps normalized session ID to app name from its signature line
session_app_map: HashMap<String, String>,
/// Maps seed correlation ID to the session ID from the same line (for app filtering)
seed_cid_sessions: HashMap<String, String>,
line_count: u64, line_count: u64,
} }
@@ -253,6 +262,8 @@ impl Pass1Result {
self.change_session_map.extend(other.change_session_map); self.change_session_map.extend(other.change_session_map);
self.sessions_with_signature self.sessions_with_signature
.extend(other.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.line_count += other.line_count;
self self
} }
@@ -264,10 +275,16 @@ fn process_line_pass1(trimmed: &str, query: &str, result: &mut Pass1Result) {
.and_then(|c| c.get(1)) .and_then(|c| c.get(1))
.map(|m| normalize_session_id(m.as_str())); .map(|m| normalize_session_id(m.as_str()));
if trimmed.contains(r#"msg="signature:"#) // Detect signature lines using broad regex (matches both msg="signature:APP/..."
&& let Some(s) = sid // 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.sessions_with_signature.insert(s.to_string());
result
.session_app_map
.insert(s.to_string(), app.to_string());
}
} }
if trimmed.contains("changeSessionId:") if trimmed.contains("changeSessionId:")
@@ -283,6 +300,15 @@ fn process_line_pass1(trimmed: &str, query: &str, result: &mut Pass1Result) {
&& s != "noSession" && s != "noSession"
{ {
result.seed_session_ids.insert(s.to_string()); 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 if let Some(cid) = CORRELATION_ID_RE
.captures(trimmed) .captures(trimmed)
@@ -550,6 +576,118 @@ fn run_pass2(
Ok(total) 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<String>,
change_map: &HashMap<String, String>,
session_app_map: &HashMap<String, String>,
app_filters: &[String],
) -> HashSet<String> {
// 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<String> = 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<String> = 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;