diff --git a/Cargo.lock b/Cargo.lock index 791bd2f..1cf2e84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2784,6 +2784,7 @@ dependencies = [ "tempfile", "tokio", "tracing", + "tracing-appender", "tracing-subscriber", "uuid", "webkit2gtk", @@ -5314,6 +5315,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "symphonia" version = "0.5.5" @@ -6370,6 +6377,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" diff --git a/crates/storage/src/file_storage.rs b/crates/storage/src/file_storage.rs index 89ef059..bb4cf9b 100644 --- a/crates/storage/src/file_storage.rs +++ b/crates/storage/src/file_storage.rs @@ -21,8 +21,16 @@ pub fn crashes_dir() -> PathBuf { lumotia_core::paths::app_paths().crashes_dir() } -/// Directory for the rolling Rust log file (lumotia.log + rotated lumotia.log.1, etc). -/// Subscribers configured in src-tauri/src/lib.rs at startup. +/// Directory for the rolling Rust log file. +/// +/// The base filename is `lumotia.log`; daily rotation produces dated +/// siblings (e.g. `lumotia.log.2026-05-12`). The appender keeps the +/// 7 most-recent days and prunes older files on rotation. +/// +/// Subscriber and rotation policy are configured by `init_tracing` in +/// `src-tauri/src/lib.rs` at startup; the diagnostic-report bundler in +/// `src-tauri/src/commands/diagnostics.rs` attaches the live file from +/// this directory to user-submitted crash reports. pub fn logs_dir() -> PathBuf { lumotia_core::paths::app_paths().logs_dir() } diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2ff9f59..0e46667 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -54,6 +54,11 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +# Rolling daily file appender that backs `lumotia.log` (the forensic log +# bundled into user-submitted diagnostic reports — see +# src-tauri/src/commands/diagnostics.rs and crates/storage/src/file_storage.rs). +# 0.2.3+ is required for `max_log_files`; 0.2.5 is the current crates.io head. +tracing-appender = "0.2.5" # Async runtime (spawn_blocking for inference) tokio = { version = "1", features = ["rt", "sync"] } diff --git a/src-tauri/src/commands/llm.rs b/src-tauri/src/commands/llm.rs index 6a19029..970d43e 100644 --- a/src-tauri/src/commands/llm.rs +++ b/src-tauri/src/commands/llm.rs @@ -188,6 +188,7 @@ pub async fn test_llm_model( window: tauri::WebviewWindow, state: State<'_, AppState>, model_id: String, + use_gpu: Option, ) -> Result { ensure_main_window(&window)?; let id = parse_model_id(model_id)?; @@ -238,12 +239,19 @@ pub async fn test_llm_model( }); } - // Not currently loaded. Attempt a real load (with GPU by default - // — matches load_llm_model's default) and classify any failure. + // Not currently loaded. Attempt a real load with the caller's + // GPU preference (matching load_llm_model's default of `true` when + // unspecified). Hard-coding `true` here used to race with a parallel + // load_llm_model(use_gpu=false) — LlmEngine::load_model decides + // "same model?" via (id, path, use_gpu) triple-equality and tears + // the engine down on mismatch, silently flipping the user's GPU + // mode underneath the in-flight test. + let resolved_use_gpu = use_gpu.unwrap_or(true); let engine = state.llm_engine.clone(); - let load_result = tokio::task::spawn_blocking(move || engine.load_model(id, &path, true)) - .await - .map_err(|e| e.to_string())?; + let load_result = + tokio::task::spawn_blocking(move || engine.load_model(id, &path, resolved_use_gpu)) + .await + .map_err(|e| e.to_string())?; match load_result { Ok(()) => Ok(LlmTestResult { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b0444ca..c7b2c9d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,13 +5,18 @@ mod tauri_app_data_migration; #[cfg(not(target_os = "android"))] mod tray; +use std::path::Path; use std::sync::Arc; -use std::sync::Once; +use std::sync::{Once, OnceLock}; use std::time::Instant; use sqlx::SqlitePool; use tauri::Manager; -use tracing_subscriber::EnvFilter; +use tracing_appender::non_blocking::WorkerGuard; +use tracing_appender::rolling::{RollingFileAppender, Rotation}; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, Layer}; use lumotia_core::paths::{ check_target_ambiguity, migrate_legacy_data_dir, MigrationStatus, @@ -31,6 +36,13 @@ use lumotia_transcription::LocalEngine; static TRACING_INIT: Once = Once::new(); +/// Parks the non-blocking file-appender's `WorkerGuard` for the process +/// lifetime. Dropping the guard halts the background log-writing thread +/// and discards any pending log lines, so it must outlive every +/// `tracing::*!` call. A `OnceLock` here gives us a free `'static` +/// home and matches the `Once`-guarded init pattern above. +static FILE_APPENDER_GUARD: OnceLock = OnceLock::new(); + /// Shared app state holding the transcription engines and database pool. pub struct AppState { pub whisper_engine: Arc, @@ -165,18 +177,112 @@ fn warn_if_x11_env_unset_on_wayland() { } } +/// Default `EnvFilter` string for the stderr (developer-facing) layer. +/// +/// `lumotia_lib::commands::live=info` is listed explicitly so that the +/// operator's documented triage filter (per +/// docs/superpowers/audits/2026-05-10-phase10a-dogfood-notes.md) works +/// without a manual `RUST_LOG=` override on every dogfooding session. +const DEFAULT_STDERR_FILTER: &str = + "warn,lumotia=info,lumotia_lib=info,lumotia_lib::commands::live=info,lumotia_core=info,lumotia_audio=info,lumotia_hotkey=info,lumotia_ai_formatting=info,lumotia_llm=info,lumotia_storage=info,lumotia_transcription=info,lumotia_startup=info"; + +/// Default `EnvFilter` string for the rolling-file (forensic) layer. +/// +/// More verbose than stderr by design: the file is what users attach to +/// diagnostic reports, so DEBUG-level Lumotia internals are worth the +/// disk cost. Third-party crates that spam at DEBUG (`sqlx`, `hyper`, +/// `reqwest`, `h2`, `rustls`, `tokio_util`) are pinned at INFO so they +/// don't drown out our own log lines. +const DEFAULT_FILE_FILTER: &str = + "info,lumotia=debug,lumotia_lib=debug,lumotia_lib::commands::live=debug,lumotia_core=debug,lumotia_audio=debug,lumotia_hotkey=debug,lumotia_ai_formatting=debug,lumotia_llm=debug,lumotia_storage=debug,lumotia_transcription=debug,lumotia_startup=debug,sqlx=info,hyper=info,reqwest=info,h2=info,rustls=info,tokio_util=info"; + +/// Build the stderr-side `EnvFilter`, honouring `RUST_LOG` if set. +fn stderr_env_filter() -> EnvFilter { + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(DEFAULT_STDERR_FILTER)) +} + +/// Build the file-side `EnvFilter`, honouring `LUMOTIA_LOG_FILE` if set +/// (falling back to `RUST_LOG`, then the verbose default). +fn file_env_filter() -> EnvFilter { + EnvFilter::try_from_env("LUMOTIA_LOG_FILE") + .or_else(|_| EnvFilter::try_from_default_env()) + .unwrap_or_else(|_| EnvFilter::new(DEFAULT_FILE_FILTER)) +} + +/// Build the rolling daily appender for `lumotia.log` inside `logs_dir`. +/// +/// Rotation policy: one file per day, keep 7 most-recent days. Older +/// files are pruned by `tracing-appender` itself. The base filename is +/// `lumotia.log`; rotated files get a date suffix (e.g. +/// `lumotia.log.2026-05-12`). +fn build_rolling_appender(logs_dir: &Path) -> std::io::Result { + std::fs::create_dir_all(logs_dir)?; + RollingFileAppender::builder() + .rotation(Rotation::DAILY) + .filename_prefix("lumotia") + .filename_suffix("log") + .max_log_files(7) + .build(logs_dir) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)) +} + +/// Install a `tracing` subscriber that writes to stderr (developer +/// stream, ANSI-coloured) AND to a rolling daily file at +/// `logs_dir/lumotia.log` (forensic stream, plain text). The file +/// stream is what diagnostic-report bundles attach. +/// +/// Returns the `WorkerGuard` for the file appender so the caller can +/// park it for the process lifetime; dropping it discards pending logs. +/// Returns `None` if the file appender could not be built (e.g. +/// permissions); in that case only the stderr layer is installed. +/// +/// Exposed `pub` so the `tracing_appender_smoke` integration test can +/// drive it against a tempdir; production code calls it via +/// `init_tracing` against the platform-default `logs_dir()`. +pub fn install_subscriber(logs_dir: &Path) -> Option { + let stderr_layer = tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_filter(stderr_env_filter()); + + match build_rolling_appender(logs_dir) { + Ok(appender) => { + let (non_blocking, guard) = tracing_appender::non_blocking(appender); + let file_layer = tracing_subscriber::fmt::layer() + .with_writer(non_blocking) + .with_ansi(false) + .with_filter(file_env_filter()); + + let _ = tracing_subscriber::registry() + .with(stderr_layer) + .with(file_layer) + .try_init(); + Some(guard) + } + Err(e) => { + // Best-effort: keep stderr logging even if the file path is + // unwritable, and surface the failure to stderr so dogfooders + // notice the missing forensic stream. + let _ = tracing_subscriber::registry() + .with(stderr_layer) + .try_init(); + eprintln!( + "lumotia: failed to install rolling file log appender at {}: {e}", + logs_dir.display() + ); + None + } + } +} + fn init_tracing() { TRACING_INIT.call_once(|| { - let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { - EnvFilter::new( - "warn,lumotia=info,lumotia_lib=info,lumotia_core=info,lumotia_audio=info,lumotia_hotkey=info,lumotia_ai_formatting=info,lumotia_llm=info,lumotia_storage=info,lumotia_transcription=info,lumotia_startup=info", - ) - }); - - let _ = tracing_subscriber::fmt() - .with_env_filter(env_filter) - .with_writer(std::io::stderr) - .try_init(); + let logs_dir = lumotia_storage::file_storage::logs_dir(); + if let Some(guard) = install_subscriber(&logs_dir) { + // Park the guard for the process lifetime. set() only fails + // if already set (impossible inside Once::call_once), so we + // ignore the result. + let _ = FILE_APPENDER_GUARD.set(guard); + } }); } diff --git a/src-tauri/tests/tracing_appender_smoke.rs b/src-tauri/tests/tracing_appender_smoke.rs new file mode 100644 index 0000000..d3ce7ff --- /dev/null +++ b/src-tauri/tests/tracing_appender_smoke.rs @@ -0,0 +1,87 @@ +//! Smoke test for the rolling-file tracing appender wired into +//! `init_tracing`. Pre-fix, `init_tracing` only installed an stderr +//! writer — meaning `lumotia.log` (referenced by the diagnostic-report +//! bundler and documented in `crates/storage/src/file_storage.rs`) was +//! never written and every user crash report shipped an empty log +//! attachment. +//! +//! This test asserts the post-fix behaviour: calling `install_subscriber` +//! against a tempdir creates the rolling log file and writes any +//! emitted `tracing::*!` lines to it after the worker thread drains. +//! +//! Notes: +//! - The non-blocking appender writes asynchronously. We force the +//! drain by dropping the returned `WorkerGuard` before reading the +//! file (the guard's drop blocks until the queue is empty). +//! - `tracing-subscriber`'s global dispatcher can only be initialised +//! once per process; integration tests each run in their own process +//! binary so this is safe. +//! - The default file-side `EnvFilter` includes `lumotia=debug` and +//! `lumotia_lib=debug`. We emit the test line with explicit target +//! `"lumotia_lib"` so the filter allows it through regardless of +//! where this test's module path lives. + +use std::fs; +use std::thread::sleep; +use std::time::Duration; + +use tempfile::tempdir; + +#[test] +fn init_tracing_creates_log_file() { + let dir = tempdir().expect("create tempdir"); + let logs_dir = dir.path().to_path_buf(); + + // Install the subscriber against the tempdir. The guard is returned + // so the test can deterministically flush by dropping it below. + let guard = lumotia_lib::install_subscriber(&logs_dir) + .expect("install_subscriber should succeed on a writable tempdir"); + + // Emit a known-unique marker. `target: "lumotia_lib"` matches the + // default file-side EnvFilter (`lumotia_lib=debug`). + tracing::info!(target: "lumotia_lib", "tracing_appender_smoke_marker_xyzzy"); + + // Drop the guard to flush the non-blocking worker queue. After this + // returns, any buffered log lines have been written to disk. + drop(guard); + + // Belt-and-braces: a brief sleep in case the OS hasn't yet made + // the write visible to a subsequent read (unlikely on Linux, but + // cheap insurance for CI on macOS/Windows). + sleep(Duration::from_millis(50)); + + // Find any file matching `lumotia*log*` in the logs dir. The daily + // rotation policy means the live file is named like + // `lumotia.log.2026-05-12` (date suffix appended by the appender). + let entries: Vec<_> = fs::read_dir(&logs_dir) + .expect("read tempdir") + .filter_map(Result::ok) + .filter(|e| { + e.file_name() + .to_string_lossy() + .starts_with("lumotia") + }) + .collect(); + + assert!( + !entries.is_empty(), + "no lumotia*.log file was created in {}", + logs_dir.display() + ); + + // At least one of those files must contain the marker we emitted. + let mut found = false; + for entry in &entries { + let contents = fs::read_to_string(entry.path()).unwrap_or_default(); + if contents.contains("tracing_appender_smoke_marker_xyzzy") { + found = true; + break; + } + } + + assert!( + found, + "marker line not found in any of {:?}", + entries.iter().map(|e| e.path()).collect::>() + ); +}