feat(cli): Add verbosity controls, JSON log format, and triple-layer subscriber

Overhaul the CLI logging infrastructure for production observability:

CLI flags:
- Add -v/-vv/-vvv (--verbose) for progressive stderr verbosity control:
  0=INFO, 1=DEBUG app, 2=DEBUG all, 3+=TRACE
- Add --log-format text|json for structured stderr output in automation
- Existing -q/--quiet overrides verbosity for silent operation

Subscriber architecture (main.rs):
- Replace single-layer subscriber with triple-layer setup:
  1. stderr layer: human-readable or JSON, filtered by -v flags
  2. file layer: always-on JSON to daily-rotated logs (lore.YYYY-MM-DD.log)
  3. MetricsLayer: captures span timing for robot-mode performance payloads
- Parse CLI before subscriber init so verbosity is known at setup time
- Load LoggingConfig early (with graceful fallback for pre-init commands)
- Clean up old log files before subscriber init to avoid holding deleted handles
- Hold WorkerGuard at function scope to ensure flush on exit

Doctor command:
- Add logging health check: validates log directory exists, reports file
  count and total size, warns on missing or inaccessible log directory

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-04 10:01:28 -05:00
parent 329c8f4539
commit 362503d3bf
3 changed files with 294 additions and 84 deletions

View File

@@ -6,7 +6,7 @@ use serde::Serialize;
use crate::core::config::Config;
use crate::core::db::{create_connection, get_schema_version, verify_pragmas};
use crate::core::error::LoreError;
use crate::core::paths::{get_config_path, get_db_path};
use crate::core::paths::{get_config_path, get_db_path, get_log_dir};
use crate::gitlab::GitLabClient;
#[derive(Debug, Clone, Serialize)]
@@ -37,6 +37,7 @@ pub struct DoctorChecks {
pub gitlab: GitLabCheck,
pub projects: ProjectsCheck,
pub ollama: OllamaCheck,
pub logging: LoggingCheck,
}
#[derive(Debug, Serialize)]
@@ -87,6 +88,18 @@ pub struct OllamaCheck {
pub model: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct LoggingCheck {
#[serde(flatten)]
pub result: CheckResult,
#[serde(skip_serializing_if = "Option::is_none")]
pub log_dir: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_bytes: Option<u64>,
}
/// Run the doctor command.
pub async fn run_doctor(config_path: Option<&str>) -> DoctorResult {
let config_path_buf = get_config_path(config_path);
@@ -107,7 +120,10 @@ pub async fn run_doctor(config_path: Option<&str>) -> DoctorResult {
// Check Ollama
let ollama_check = check_ollama(config.as_ref()).await;
// Success if all required checks pass (ollama is optional)
// Check logging
let logging_check = check_logging(config.as_ref());
// Success if all required checks pass (ollama and logging are optional)
let success = config_check.result.status == CheckStatus::Ok
&& database_check.result.status == CheckStatus::Ok
&& gitlab_check.result.status == CheckStatus::Ok
@@ -121,6 +137,7 @@ pub async fn run_doctor(config_path: Option<&str>) -> DoctorResult {
gitlab: gitlab_check,
projects: projects_check,
ollama: ollama_check,
logging: logging_check,
},
}
}
@@ -457,6 +474,59 @@ async fn check_ollama(config: Option<&Config>) -> OllamaCheck {
}
}
fn check_logging(config: Option<&Config>) -> LoggingCheck {
let log_dir = get_log_dir(config.and_then(|c| c.logging.log_dir.as_deref()));
let log_dir_str = log_dir.display().to_string();
if !log_dir.exists() {
return LoggingCheck {
result: CheckResult {
status: CheckStatus::Ok,
message: Some(format!("{log_dir_str} (no log files yet)")),
},
log_dir: Some(log_dir_str),
file_count: Some(0),
total_bytes: Some(0),
};
}
let mut file_count = 0usize;
let mut total_bytes = 0u64;
if let Ok(entries) = std::fs::read_dir(&log_dir) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with("lore.") {
file_count += 1;
if let Ok(meta) = entry.metadata() {
total_bytes += meta.len();
}
}
}
}
let size_display = if total_bytes < 1024 {
format!("{total_bytes} B")
} else if total_bytes < 1024 * 1024 {
format!("{:.1} KB", total_bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", total_bytes as f64 / (1024.0 * 1024.0))
};
LoggingCheck {
result: CheckResult {
status: CheckStatus::Ok,
message: Some(format!(
"{log_dir_str} ({file_count} files, {size_display})"
)),
},
log_dir: Some(log_dir_str),
file_count: Some(file_count),
total_bytes: Some(total_bytes),
}
}
/// Format and print doctor results to console.
pub fn print_doctor_results(result: &DoctorResult) {
println!("\nlore doctor\n");
@@ -466,6 +536,7 @@ pub fn print_doctor_results(result: &DoctorResult) {
print_check("GitLab", &result.checks.gitlab.result);
print_check("Projects", &result.checks.projects.result);
print_check("Ollama", &result.checks.ollama.result);
print_check("Logging", &result.checks.logging.result);
println!();

View File

@@ -31,6 +31,14 @@ pub struct Cli {
#[arg(short = 'q', long, global = true)]
pub quiet: bool,
/// Increase log verbosity (-v, -vv, -vvv)
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true)]
pub verbose: u8,
/// Log format for stderr output: text (default) or json
#[arg(long = "log-format", global = true, value_parser = ["text", "json"], default_value = "text")]
pub log_format: String,
#[command(subcommand)]
pub command: Commands,
}