Skip to content

Commit f3dbf51

Browse files
committed
Logging: Add log-storage cap setting + DEBUG file target
- New `advanced.maxLogStorageMb` setting (default 200 MB, range 0–5000). 0 disables log storage entirely so error reports cannot be sent. - File target switched to DEBUG so error reports capture full context. Terminal rides along (`tauri-plugin-log` has no per-target levels — documented tradeoff). - Rotation: `RotationStrategy::KeepSome(N)` where `N = ceil(cap_mb / 50)`. Native plugin support, no custom pruner needed. - New `cmdr_lib::logging` module: log-dir cache, keep-count atomic, `eager_prune` for the user-lowered-the-cap case, plus `list_recent_log_files` and `current_total_log_bytes` for Phase 4. - New Tauri command `set_max_log_storage_mb` updates the in-RAM keep-count and runs an eager prune; settings applier toasts "Restart Cmdr to apply" on `0 ↔ non-zero` transitions. - Early loader reads the setting before the Tauri app handle exists (the plugin builds before `setup()`). - Bonus fix: `diskSpaceChangeThreshold` now shows "MB" instead of "px" in the Advanced UI. - Docs updated: `apps/desktop/src/lib/logging/CLAUDE.md`, `docs/tooling/logging.md`, `apps/desktop/src-tauri/src/settings/CLAUDE.md`, new `apps/desktop/src-tauri/src/logging/CLAUDE.md`.
1 parent 1a2ea1c commit f3dbf51

16 files changed

Lines changed: 584 additions & 25 deletions

File tree

apps/desktop/src-tauri/src/commands/settings.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,37 @@ pub fn set_smb_concurrency_cmd(value: u16) {
7070
set_smb_concurrency(value as usize);
7171
}
7272

73+
/// Updates the in-RAM log-storage cap and runs an eager prune so the user sees excess files
74+
/// disappear immediately when they lower the cap.
75+
///
76+
/// Note: the actual rotation strategy `tauri-plugin-log` uses is fixed at app start (the
77+
/// plugin has no runtime reconfigure API). Restart-to-apply is therefore unavoidable for
78+
/// `0 ↔ non-zero` transitions and for raising the cap above the previously baked-in value.
79+
/// The frontend toasts a "restart required" notice for those cases.
80+
///
81+
/// `value` is in MB. `0` means "log storage disabled".
82+
#[tauri::command]
83+
pub fn set_max_log_storage_mb(value: u64) -> Result<(), String> {
84+
use crate::logging;
85+
86+
let new_keep = if value == 0 { 0 } else { value.div_ceil(50) as usize };
87+
logging::set_keep_count(new_keep);
88+
89+
// Eager-prune is purely cosmetic — files would also vanish on the next rotation, but
90+
// the user just changed a setting, so we should show the effect now.
91+
if let Some(dir) = logging::log_dir() {
92+
match logging::eager_prune(dir, new_keep) {
93+
Ok(0) => {}
94+
Ok(n) => log::info!(
95+
target: "cmdr_lib::logging",
96+
"Eager-pruned {n} log files after cap change ({value} MB → keep {new_keep}).",
97+
),
98+
Err(err) => return Err(format!("Failed to eager-prune log files: {err}")),
99+
}
100+
}
101+
Ok(())
102+
}
103+
73104
/// Update menu accelerator for a command.
74105
/// Called from frontend when keyboard shortcuts are changed.
75106
#[tauri::command]

apps/desktop/src-tauri/src/lib.rs

Lines changed: 84 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ use nusb as _;
6161
mod ignore_poison;
6262
pub use ignore_poison::IgnorePoison;
6363

64+
mod logging;
65+
6466
#[cfg(target_os = "macos")]
6567
mod accent_color;
6668
#[cfg(target_os = "linux")]
@@ -216,25 +218,68 @@ pub fn run() {
216218
// 1. CMDR_LOG_DIR env var (explicit override)
217219
// 2. CMDR_DATA_DIR env var → <CMDR_DATA_DIR>/logs/ (dev and E2E test isolation)
218220
// 3. Default Tauri log dir (production)
219-
let log_target = if let Ok(log_dir) = std::env::var("CMDR_LOG_DIR") {
220-
Target::new(TargetKind::Folder {
221-
path: std::path::PathBuf::from(log_dir),
222-
file_name: None,
223-
})
221+
let resolved_log_dir: std::path::PathBuf = if let Ok(log_dir) = std::env::var("CMDR_LOG_DIR") {
222+
std::path::PathBuf::from(log_dir)
224223
} else if let Ok(data_dir) = std::env::var("CMDR_DATA_DIR") {
225-
let log_dir = std::path::PathBuf::from(data_dir).join("logs");
226-
Target::new(TargetKind::Folder {
227-
path: log_dir,
224+
std::path::PathBuf::from(data_dir).join("logs")
225+
} else {
226+
// Mirror tauri-plugin-log's `LogDir`: `<app_log_dir>/`. On macOS/iOS this is
227+
// `~/Library/Logs/<bundle_id>/`; on Linux it's `$XDG_DATA_HOME/<bundle_id>/logs`;
228+
// on Windows it's `<FOLDERID_LocalAppData>/<bundle_id>/logs`. We mirror the
229+
// macOS shape (the only platform we currently ship to) — `dirs::home_dir() +
230+
// Library/Logs/<bundle_id>`. Other platforms only matter for dev, where
231+
// CMDR_DATA_DIR is always set by `tauri-wrapper.js`.
232+
#[cfg(target_os = "macos")]
233+
{
234+
dirs::home_dir()
235+
.map(|h| h.join("Library/Logs/com.veszelovszki.cmdr"))
236+
.unwrap_or_else(|| std::path::PathBuf::from("./logs"))
237+
}
238+
#[cfg(not(target_os = "macos"))]
239+
{
240+
dirs::data_local_dir()
241+
.map(|d| d.join("com.veszelovszki.cmdr/logs"))
242+
.unwrap_or_else(|| std::path::PathBuf::from("./logs"))
243+
}
244+
};
245+
246+
// Read the log-storage cap from settings.json *before* the app handle exists.
247+
// 0 = disabled (drop the file target entirely).
248+
// None = no setting yet → 200 MB default.
249+
// Any other value = N MB cap, mapped to KeepSome(ceil(N / 50)).
250+
let cap_mb = settings::early_load_max_log_storage_mb().unwrap_or(200);
251+
let file_logging_enabled = cap_mb > 0;
252+
let keep_count: usize = if file_logging_enabled {
253+
cap_mb.div_ceil(50) as usize
254+
} else {
255+
0
256+
};
257+
258+
// Cache for the rest of the app (Phase 4 bundle builder, eager-prune callers).
259+
logging::set_log_dir(resolved_log_dir.clone());
260+
logging::set_keep_count(keep_count);
261+
262+
let stdout_target = Target::new(TargetKind::Stdout);
263+
let targets: Vec<Target> = if file_logging_enabled {
264+
let folder_target = Target::new(TargetKind::Folder {
265+
path: resolved_log_dir,
228266
file_name: None,
229-
})
267+
});
268+
vec![stdout_target, folder_target]
230269
} else {
231-
Target::new(TargetKind::LogDir { file_name: None })
270+
vec![stdout_target]
232271
};
233272

234273
let mut builder = tauri_plugin_log::Builder::new()
235-
.targets([Target::new(TargetKind::Stdout), log_target])
236-
.rotation_strategy(RotationStrategy::KeepAll)
237-
.max_file_size(50_000_000) // 50 MB
274+
.targets(targets)
275+
.rotation_strategy(if file_logging_enabled {
276+
RotationStrategy::KeepSome(keep_count)
277+
} else {
278+
// Irrelevant when there's no Folder target, but the builder needs *some*
279+
// value. KeepOne is the plugin's default and the cheapest.
280+
RotationStrategy::KeepOne
281+
})
282+
.max_file_size(50_000_000) // 50 MB per rotated file
238283
.format(|out, message, record| {
239284
let now = chrono::Local::now();
240285
let ts = now.format("%H:%M:%S%.3f"); // HH:MM:SS.mmm
@@ -280,9 +325,32 @@ pub fn run() {
280325
builder.build()
281326
})
282327
.setup(|app| {
283-
// When RUST_LOG is not set, restrict to Info by default (verbose toggle can raise to Debug)
328+
// When RUST_LOG is not set, choose a runtime floor:
329+
// - If file logging is enabled (`logging::keep_count() > 0`): Debug, so the file
330+
// target captures the full debug context error reports rely on. The plugin can't
331+
// filter per-target, so the terminal also sees Debug — documented tradeoff.
332+
// - If file logging is disabled (cap = 0): Info, preserving the old behavior. The
333+
// verbose toggle can raise to Debug at runtime.
284334
if std::env::var("RUST_LOG").is_err() {
285-
log::set_max_level(log::LevelFilter::Info);
335+
let floor = if logging::keep_count() > 0 {
336+
log::LevelFilter::Debug
337+
} else {
338+
log::LevelFilter::Info
339+
};
340+
log::set_max_level(floor);
341+
}
342+
343+
// One-line marker so the resolved log-storage state is visible at startup.
344+
match logging::keep_count() {
345+
0 => log::info!(
346+
target: "cmdr_lib::logging",
347+
"Log storage disabled (advanced.maxLogStorageMb = 0). Error reports cannot be sent.",
348+
),
349+
n => log::info!(
350+
target: "cmdr_lib::logging",
351+
"Log storage enabled: keep up to {n} files × 50 MB ({} MB cap)",
352+
n * 50,
353+
),
286354
}
287355

288356
// Initialize crash reporter early, before anything that might crash
@@ -994,6 +1062,7 @@ pub fn run() {
9941062
commands::settings::set_direct_smb_connection,
9951063
commands::settings::set_filter_safe_save_artifacts_cmd,
9961064
commands::settings::set_smb_concurrency_cmd,
1065+
commands::settings::set_max_log_storage_mb,
9971066
// Logging commands (frontend log bridge + runtime level control)
9981067
commands::logging::batch_fe_logs,
9991068
commands::logging::set_log_level,
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Logging support module
2+
3+
Lives under `src-tauri/src/logging/`. `tauri-plugin-log` owns the actual log pipeline
4+
(terminal target + rotating file target). This module fills the gaps the plugin doesn't
5+
expose.
6+
7+
## File map
8+
9+
| File | Purpose |
10+
| ---------- | ----------------------------------------------------------------------- |
11+
| `mod.rs` | `OnceLock<PathBuf>` for the resolved log dir, `AtomicUsize` for keep-count, `eager_prune`, `list_recent_log_files`, `current_total_log_bytes` |
12+
| `tests.rs` | Unit tests: pruner keep-N, zero-keep, missing dir, sorted listing, byte totals |
13+
14+
## What lives here
15+
16+
| Function | Role |
17+
| --------------------------------- | -------------------------------------------------------------------------------------- |
18+
| `set_log_dir(path)` / `log_dir()` | Cache the resolved dir once at plugin-build time; Phase 4 bundle builder reads it back |
19+
| `set_keep_count(n)` / `keep_count()` | Live view of the `KeepSome(N)` value the plugin was built with |
20+
| `list_recent_log_files(dir)` | `*.log*` files newest-first by mtime |
21+
| `current_total_log_bytes(dir)` | Sum sizes of `*.log*` files (diagnostic) |
22+
| `eager_prune(dir, keep_n)` | One-shot: delete everything beyond `keep_n` newest |
23+
24+
## Why a one-shot pruner and not a recurring task
25+
26+
`tauri-plugin-log` 2.8.0 exposes `RotationStrategy::KeepSome(usize)` natively. The plugin
27+
rotates and prunes files itself on each rotation. We don't need a 10-min recurring pruner.
28+
29+
The only gap is the "user just lowered the cap at runtime" case: the plugin won't delete
30+
existing archived files until the next rotation, which may not happen for a while. The
31+
`set_max_log_storage_mb` Tauri command calls `eager_prune` so the user sees files disappear
32+
immediately. Purely cosmetic — correctness is already guaranteed by the plugin's rotation.
33+
34+
## Plugin is one-shot
35+
36+
`tauri_plugin_log::Builder` builds the plugin exactly once. There's no runtime reconfigure
37+
API — no "switch rotation strategy", no "add Folder target", no "change max file size".
38+
Changes to the cap that need a different rotation strategy or a target list change (0 ↔
39+
non-zero transitions, raising the cap above the previously baked-in value) take effect on
40+
the next app launch.
41+
42+
This is why `set_keep_count` exists: the in-RAM count drives `eager_prune` calls without
43+
needing to restart the plugin. The next startup reads the setting again and bakes the new
44+
value into the plugin.
45+
46+
## Per-target level filtering (missing feature)
47+
48+
`tauri-plugin-log` does NOT support different log levels per target. Setting the file
49+
target to Debug also forces the terminal target to Debug for modules that don't have an
50+
explicit `RUST_LOG` filter. This is the documented tradeoff in the error-report design —
51+
worth it to get full debug context in error report bundles.
52+
53+
When `advanced.maxLogStorageMb = 0`, the file target is dropped entirely, and terminal
54+
level falls back to the old Info default. The verbose toggle continues to flip terminal
55+
gating via `log::set_max_level` in that case.
56+
57+
## Gotcha/Why
58+
59+
- `list_recent_log_files` returns `Vec<PathBuf>` in "newest-first by mtime" order. If you
60+
use it to locate the currently-active file, trust mtime, not the filename — rotated
61+
files have a timestamp suffix but there's no guarantee the suffix is current.
62+
- `eager_prune(dir, 0)` wipes everything including the live file; the plugin re-creates it
63+
on the next write. This is the correct behavior for the "user just disabled logging at
64+
runtime" path — we stop capturing immediately rather than waiting for the next restart.
65+
- The log dir resolution in `lib.rs` and in `early_load_max_log_storage_mb` must stay in
66+
sync. `lib.rs` resolves at plugin-build time with env-var precedence; the early-load
67+
helper in `settings::loader` mirrors the CMDR_DATA_DIR fallback but uses `dirs::data_dir`
68+
+ bundle id rather than Tauri's `app_data_dir`. If the bundle id changes, both places
69+
need updating.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//! Logging support module.
2+
//!
3+
//! `tauri-plugin-log` owns the actual log routing (terminal + rotating file). This module
4+
//! adds the bits the plugin doesn't expose:
5+
//!
6+
//! - **Resolved log dir cache** ([`set_log_dir`] / [`log_dir`]): the path is derived in `lib.rs`
7+
//! from `CMDR_LOG_DIR` / `CMDR_DATA_DIR` / the Tauri default. Phase 4's bundle builder needs
8+
//! the same path without re-deriving the env-var logic.
9+
//! - **Live keep-count** ([`set_keep_count`] / [`keep_count`]): the rotation `KeepSome(N)` value
10+
//! the plugin was built with. The plugin is one-shot — changing this at runtime does NOT
11+
//! reconfigure the plugin, but [`eager_prune`] uses it to delete excess archived files
12+
//! immediately when the user lowers the cap.
13+
//! - **One-shot pruner** ([`eager_prune`]): for the user-lowered-the-cap case. Files would
14+
//! also vanish on the next rotation; this gives the user immediate feedback.
15+
//! - **Listing helpers** ([`list_recent_log_files`], [`current_total_log_bytes`]): for Phase 4
16+
//! bundle building and diagnostics.
17+
18+
use std::path::{Path, PathBuf};
19+
use std::sync::OnceLock;
20+
use std::sync::atomic::{AtomicUsize, Ordering};
21+
22+
#[cfg(test)]
23+
mod tests;
24+
25+
static LOG_DIR: OnceLock<PathBuf> = OnceLock::new();
26+
static KEEP_COUNT: AtomicUsize = AtomicUsize::new(0);
27+
28+
/// Records the resolved log directory once, at plugin-build time.
29+
///
30+
/// Subsequent calls are no-ops — the path doesn't change without an app restart.
31+
pub fn set_log_dir(path: PathBuf) {
32+
let _ = LOG_DIR.set(path);
33+
}
34+
35+
/// Returns the resolved log directory if it was recorded.
36+
///
37+
/// Returns `None` when log storage is disabled (cap = 0) or before the plugin builder ran.
38+
pub fn log_dir() -> Option<&'static Path> {
39+
LOG_DIR.get().map(PathBuf::as_path)
40+
}
41+
42+
/// Records the keep-count the plugin was built with (`ceil(cap_mb / 50)`).
43+
pub fn set_keep_count(n: usize) {
44+
KEEP_COUNT.store(n, Ordering::Relaxed);
45+
}
46+
47+
/// Current keep-count.
48+
///
49+
/// Used by [`eager_prune`] callers and reported in diagnostics. `0` means "log storage
50+
/// disabled" (the `Folder` target was dropped at build time).
51+
pub fn keep_count() -> usize {
52+
KEEP_COUNT.load(Ordering::Relaxed)
53+
}
54+
55+
/// Lists `*.log*` files in the log dir, newest first by mtime.
56+
///
57+
/// Returns an empty `Vec` if the directory doesn't exist or can't be read. The "live" file
58+
/// (no rotation suffix) sorts ahead of archived `cmdr.log.YYYY-MM-DD-HH-MM-SS` siblings as
59+
/// long as its mtime is fresher, which is the case during normal operation.
60+
pub fn list_recent_log_files(log_dir: &Path) -> Vec<PathBuf> {
61+
let Ok(entries) = std::fs::read_dir(log_dir) else {
62+
return Vec::new();
63+
};
64+
65+
let mut files: Vec<(PathBuf, std::time::SystemTime)> = entries
66+
.filter_map(Result::ok)
67+
.filter_map(|entry| {
68+
let path = entry.path();
69+
// Match anything containing ".log" — covers `cmdr.log` and rotated
70+
// `cmdr.log.2025-01-15-...` siblings without hard-coding a stem.
71+
let name = path.file_name()?.to_str()?;
72+
if !name.contains(".log") {
73+
return None;
74+
}
75+
let mtime = entry.metadata().ok()?.modified().ok()?;
76+
Some((path, mtime))
77+
})
78+
.collect();
79+
80+
// Newest first — `Reverse` avoids the `sort_by`/`Ord` boilerplate clippy flags.
81+
files.sort_by_key(|(_, mtime)| std::cmp::Reverse(*mtime));
82+
files.into_iter().map(|(p, _)| p).collect()
83+
}
84+
85+
/// Sums sizes of all `*.log*` files in the log dir. Returns `0` if the dir is missing.
86+
#[allow(dead_code, reason = "Diagnostic helper; wired up by Phase 4 bundle manifest")]
87+
pub fn current_total_log_bytes(log_dir: &Path) -> u64 {
88+
list_recent_log_files(log_dir)
89+
.into_iter()
90+
.filter_map(|p| std::fs::metadata(&p).ok().map(|m| m.len()))
91+
.sum()
92+
}
93+
94+
/// Deletes all but the `keep_n` newest `*.log*` files in `log_dir`.
95+
///
96+
/// One-shot — call once after the user lowers the cap. Returns the number of files deleted.
97+
/// A missing directory is not an error (returns `Ok(0)`). Per-file deletion errors are
98+
/// logged but don't abort the sweep.
99+
///
100+
/// `keep_n == 0` keeps no files at all — used when the user disables log storage at runtime
101+
/// (the live `cmdr.log` will be re-created by the plugin on the next write).
102+
pub fn eager_prune(log_dir: &Path, keep_n: usize) -> std::io::Result<usize> {
103+
if !log_dir.exists() {
104+
return Ok(0);
105+
}
106+
let files = list_recent_log_files(log_dir);
107+
if files.len() <= keep_n {
108+
return Ok(0);
109+
}
110+
let mut deleted = 0usize;
111+
for path in files.into_iter().skip(keep_n) {
112+
match std::fs::remove_file(&path) {
113+
Ok(()) => deleted += 1,
114+
Err(err) => log::warn!(
115+
target: "cmdr_lib::logging",
116+
"Failed to prune log file {}: {err}",
117+
path.display(),
118+
),
119+
}
120+
}
121+
Ok(deleted)
122+
}

0 commit comments

Comments
 (0)