feat(observability): Add metrics, logging, and sync-run core modules
Introduce the foundational observability layer for the sync pipeline: - MetricsLayer: Custom tracing subscriber layer that captures span timing and structured fields, materializing them into a hierarchical Vec<StageTiming> tree for robot-mode performance data output - logging: Dual-layer subscriber infrastructure with configurable stderr verbosity (-v/-vv/-vvv) and always-on JSON file logging with daily rotation and configurable retention (default 30 days) - SyncRunRecorder: Compile-time enforced lifecycle recorder for sync_runs table (start -> succeed|fail), with correlation IDs and aggregate counts - LoggingConfig: New config section for log_dir, retention_days, and file_logging toggle - get_log_dir(): Path helper for log directory resolution - is_permanent_api_error(): Distinguish retryable vs permanent API failures (only 404 is truly permanent; 403/auth errors may be environmental) Database changes: - Migration 013: Add resource_events_synced_for_updated_at watermark columns to issues and merge_requests tables for incremental resource event sync - Migration 014: Enrich sync_runs with run_id correlation ID, aggregate counts (total_items_processed, total_errors), and run_id index - Wrap file-based migrations in savepoints for rollback safety Dependencies: Add uuid (run_id generation), tracing-appender (file logging) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
217
src/core/logging.rs
Normal file
217
src/core/logging.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
//! Logging infrastructure: dual-layer subscriber setup and log file retention.
|
||||
//!
|
||||
//! Provides a layered tracing subscriber with:
|
||||
//! - **stderr layer**: Human-readable or JSON format, controlled by `-v` flags
|
||||
//! - **file layer**: Always-on JSON output to daily-rotated log files
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
/// Build an `EnvFilter` from the verbosity count.
|
||||
///
|
||||
/// | Count | App Level | Dep Level |
|
||||
/// |-------|-----------|-----------|
|
||||
/// | 0 | INFO | WARN |
|
||||
/// | 1 | DEBUG | WARN |
|
||||
/// | 2 | DEBUG | INFO |
|
||||
/// | 3+ | TRACE | DEBUG |
|
||||
pub fn build_stderr_filter(verbose: u8, quiet: bool) -> EnvFilter {
|
||||
// RUST_LOG always wins if set
|
||||
if std::env::var("RUST_LOG").is_ok() {
|
||||
return EnvFilter::from_default_env();
|
||||
}
|
||||
|
||||
// -q overrides -v for stderr
|
||||
if quiet {
|
||||
return EnvFilter::new("lore=warn,error");
|
||||
}
|
||||
|
||||
let directives = match verbose {
|
||||
0 => "lore=info,warn",
|
||||
1 => "lore=debug,warn",
|
||||
2 => "lore=debug,info",
|
||||
_ => "lore=trace,debug",
|
||||
};
|
||||
|
||||
EnvFilter::new(directives)
|
||||
}
|
||||
|
||||
/// Build an `EnvFilter` for the file layer.
|
||||
///
|
||||
/// Always captures DEBUG+ for `lore::*` and WARN+ for dependencies,
|
||||
/// unless `RUST_LOG` is set (which overrides everything).
|
||||
pub fn build_file_filter() -> EnvFilter {
|
||||
if std::env::var("RUST_LOG").is_ok() {
|
||||
return EnvFilter::from_default_env();
|
||||
}
|
||||
|
||||
EnvFilter::new("lore=debug,warn")
|
||||
}
|
||||
|
||||
/// Delete log files older than `retention_days` from the given directory.
|
||||
///
|
||||
/// Only deletes files matching the `lore.YYYY-MM-DD.log` pattern.
|
||||
/// Returns the number of files deleted.
|
||||
pub fn cleanup_old_logs(log_dir: &Path, retention_days: u32) -> usize {
|
||||
if retention_days == 0 || !log_dir.exists() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(retention_days));
|
||||
let cutoff_date = cutoff.format("%Y-%m-%d").to_string();
|
||||
let mut deleted = 0;
|
||||
|
||||
let entries = match fs::read_dir(log_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.to_string_lossy();
|
||||
|
||||
// Match pattern: lore.YYYY-MM-DD.log or lore.YYYY-MM-DD (tracing-appender format)
|
||||
if let Some(date_str) = extract_log_date(&name)
|
||||
&& date_str < cutoff_date
|
||||
&& fs::remove_file(entry.path()).is_ok()
|
||||
{
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
deleted
|
||||
}
|
||||
|
||||
/// Extract the date portion from a log filename.
|
||||
///
|
||||
/// Matches: `lore.YYYY-MM-DD.log` or `lore.YYYY-MM-DD`
|
||||
fn extract_log_date(filename: &str) -> Option<String> {
|
||||
let rest = filename.strip_prefix("lore.")?;
|
||||
|
||||
// Must have at least YYYY-MM-DD (10 ASCII chars).
|
||||
// Use get() to avoid panicking on non-ASCII filenames.
|
||||
let date_part = rest.get(..10)?;
|
||||
|
||||
// Validate it looks like a date
|
||||
let parts: Vec<&str> = date_part.split('-').collect();
|
||||
if parts.len() != 3 || parts[0].len() != 4 || parts[1].len() != 2 || parts[2].len() != 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check all parts are numeric (also ensures ASCII)
|
||||
if !parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// After the date, must be end-of-string or ".log"
|
||||
let suffix = rest.get(10..)?;
|
||||
if suffix.is_empty() || suffix == ".log" {
|
||||
Some(date_part.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::File;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_extract_log_date_with_extension() {
|
||||
assert_eq!(
|
||||
extract_log_date("lore.2026-02-04.log"),
|
||||
Some("2026-02-04".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_log_date_without_extension() {
|
||||
assert_eq!(
|
||||
extract_log_date("lore.2026-02-04"),
|
||||
Some("2026-02-04".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_log_date_rejects_non_log_files() {
|
||||
assert_eq!(extract_log_date("other.txt"), None);
|
||||
assert_eq!(extract_log_date("lore.config.json"), None);
|
||||
assert_eq!(extract_log_date("lore.db"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_log_date_rejects_invalid_dates() {
|
||||
assert_eq!(extract_log_date("lore.not-a-date.log"), None);
|
||||
assert_eq!(extract_log_date("lore.20260204.log"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_old_logs_deletes_old_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
// Create old log files (well before any reasonable retention)
|
||||
File::create(dir.path().join("lore.2020-01-01.log")).unwrap();
|
||||
File::create(dir.path().join("lore.2020-01-15.log")).unwrap();
|
||||
|
||||
// Create a recent log file (today)
|
||||
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||
let recent_name = format!("lore.{today}.log");
|
||||
File::create(dir.path().join(&recent_name)).unwrap();
|
||||
|
||||
// Create a non-log file that should NOT be deleted
|
||||
File::create(dir.path().join("other.txt")).unwrap();
|
||||
|
||||
let deleted = cleanup_old_logs(dir.path(), 7);
|
||||
|
||||
assert_eq!(deleted, 2);
|
||||
assert!(!dir.path().join("lore.2020-01-01.log").exists());
|
||||
assert!(!dir.path().join("lore.2020-01-15.log").exists());
|
||||
assert!(dir.path().join(&recent_name).exists());
|
||||
assert!(dir.path().join("other.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_old_logs_zero_retention_is_noop() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
File::create(dir.path().join("lore.2020-01-01.log")).unwrap();
|
||||
|
||||
let deleted = cleanup_old_logs(dir.path(), 0);
|
||||
assert_eq!(deleted, 0);
|
||||
assert!(dir.path().join("lore.2020-01-01.log").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_old_logs_nonexistent_dir() {
|
||||
let deleted = cleanup_old_logs(Path::new("/nonexistent/dir"), 7);
|
||||
assert_eq!(deleted, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_stderr_filter_default() {
|
||||
// Can't easily assert filter contents, but verify it doesn't panic
|
||||
let _filter = build_stderr_filter(0, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_stderr_filter_verbose_levels() {
|
||||
let _f0 = build_stderr_filter(0, false);
|
||||
let _f1 = build_stderr_filter(1, false);
|
||||
let _f2 = build_stderr_filter(2, false);
|
||||
let _f3 = build_stderr_filter(3, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_stderr_filter_quiet_overrides_verbose() {
|
||||
// Quiet should win over verbose
|
||||
let _filter = build_stderr_filter(3, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_file_filter() {
|
||||
let _filter = build_file_filter();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user