feat(trace,file-history): add tracing instrumentation and diagnostic hints

Add structured tracing spans to trace and file-history pipelines so debug
logging (-vv) shows path resolution counts, MR match counts, and discussion
counts at each stage. This makes empty-result debugging straightforward.

Add a hints field to TraceResult and FileHistoryResult that carries
machine-readable diagnostic strings explaining *why* results may be empty
(e.g., "Run 'lore sync' to fetch MR file changes"). The CLI renders these
as info lines; robot mode includes them in JSON when non-empty.

Also: fix filter_map(Result::ok) → collect::<Result> in trace.rs (same
pattern fixed in prior commit for file_history/path_resolver), and switch
conn.prepare → conn.prepare_cached for the MR query.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-18 13:28:37 -05:00
parent c0ca501662
commit 8442bcf367
3 changed files with 192 additions and 27 deletions

View File

@@ -1,4 +1,5 @@
use serde::Serialize;
use tracing::info;
use super::error::Result;
use super::file_history::resolve_rename_chain;
@@ -51,6 +52,9 @@ pub struct TraceResult {
pub renames_followed: bool,
pub trace_chains: Vec<TraceChain>,
pub total_chains: usize,
/// Diagnostic hints explaining why results may be empty.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub hints: Vec<String>,
}
/// Run the trace query: file -> MR -> issue chain.
@@ -75,6 +79,14 @@ pub fn run_trace(
(vec![path.to_string()], false)
};
info!(
paths = all_paths.len(),
renames_followed,
"trace: resolved {} path(s) for '{}'",
all_paths.len(),
path
);
// Build placeholders for IN clause
let placeholders: Vec<String> = (0..all_paths.len())
.map(|i| format!("?{}", i + 2))
@@ -100,7 +112,7 @@ pub fn run_trace(
all_paths.len() + 2
);
let mut stmt = conn.prepare(&mr_sql)?;
let mut stmt = conn.prepare_cached(&mr_sql)?;
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
params.push(Box::new(project_id.unwrap_or(0)));
@@ -137,8 +149,14 @@ pub fn run_trace(
web_url: row.get(8)?,
})
})?
.filter_map(std::result::Result::ok)
.collect();
.collect::<std::result::Result<Vec<_>, _>>()?;
info!(
mr_count = mr_rows.len(),
"trace: found {} MR(s) touching '{}'",
mr_rows.len(),
path
);
// Step 2: For each MR, find linked issues + optional discussions
let mut trace_chains = Vec::with_capacity(mr_rows.len());
@@ -152,6 +170,16 @@ pub fn run_trace(
Vec::new()
};
info!(
mr_iid = mr.iid,
issues = issues.len(),
discussions = discussions.len(),
"trace: MR !{}: {} issue(s), {} discussion(s)",
mr.iid,
issues.len(),
discussions.len()
);
trace_chains.push(TraceChain {
mr_iid: mr.iid,
mr_title: mr.title.clone(),
@@ -168,12 +196,20 @@ pub fn run_trace(
let total_chains = trace_chains.len();
// Build diagnostic hints when no results found
let hints = if total_chains == 0 {
build_trace_hints(conn, project_id, &all_paths)?
} else {
Vec::new()
};
Ok(TraceResult {
path: path.to_string(),
resolved_paths: all_paths,
renames_followed,
trace_chains,
total_chains,
hints,
})
}
@@ -191,7 +227,7 @@ fn fetch_linked_issues(conn: &rusqlite::Connection, mr_id: i64) -> Result<Vec<Tr
CASE er.reference_type WHEN 'closes' THEN 0 WHEN 'related' THEN 1 ELSE 2 END, \
i.iid";
let mut stmt = conn.prepare(sql)?;
let mut stmt = conn.prepare_cached(sql)?;
let issues: Vec<TraceIssue> = stmt
.query_map(rusqlite::params![mr_id], |row| {
Ok(TraceIssue {
@@ -202,8 +238,7 @@ fn fetch_linked_issues(conn: &rusqlite::Connection, mr_id: i64) -> Result<Vec<Tr
web_url: row.get(4)?,
})
})?
.filter_map(std::result::Result::ok)
.collect();
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(issues)
}
@@ -225,11 +260,10 @@ fn fetch_trace_discussions(
WHERE d.merge_request_id = ?1 \
AND n.position_new_path IN ({in_clause}) \
AND n.is_system = 0 \
ORDER BY n.created_at DESC \
LIMIT 20"
ORDER BY n.created_at DESC"
);
let mut stmt = conn.prepare(&sql)?;
let mut stmt = conn.prepare_cached(&sql)?;
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
params.push(Box::new(mr_id));
@@ -251,12 +285,57 @@ fn fetch_trace_discussions(
created_at_iso: ms_to_iso(created_at),
})
})?
.filter_map(std::result::Result::ok)
.collect();
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(discussions)
}
/// Build diagnostic hints explaining why a trace query returned no results.
fn build_trace_hints(
conn: &rusqlite::Connection,
project_id: Option<i64>,
paths: &[String],
) -> Result<Vec<String>> {
let mut hints = Vec::new();
// Check if mr_file_changes has ANY rows for this project
let has_file_changes: bool = if let Some(pid) = project_id {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM mr_file_changes WHERE project_id = ?1 LIMIT 1)",
rusqlite::params![pid],
|row| row.get(0),
)?
} else {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM mr_file_changes LIMIT 1)",
[],
|row| row.get(0),
)?
};
if !has_file_changes {
hints.push(
"No MR file changes have been synced yet. Run 'lore sync' to fetch file change data."
.to_string(),
);
return Ok(hints);
}
// File changes exist but none match these paths
let path_list = paths
.iter()
.map(|p| format!("'{p}'"))
.collect::<Vec<_>>()
.join(", ");
hints.push(format!(
"Searched paths [{}] were not found in MR file changes. \
The file may predate the sync window or use a different path.",
path_list
));
Ok(hints)
}
#[cfg(test)]
#[path = "trace_tests.rs"]
mod tests;