🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 22m12s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Successful in 37s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 11s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m10s
The app forgot everything it did the moment it exited. The Rust half
logged through env_logger to stdout only -- invisible to anyone who
launched from a desktop icon, and on Android worse than that: stdout is
not logcat, so the backend produced no visible output at all on the
platform carrying this project's hardest bugs. The autoplay deadlock,
the truncated-stream restart and the background-audio stall were all
diagnosed by talking a user through `adb logcat`, because there was no
other way to see anything. A panic left nothing behind at all.
Logs now go to a size-capped rotating file, to logcat on Android, and to
the webview console in dev. A panic is recorded with its backtrace before
the process dies. The frontend's messages are forwarded into the same
file, so one timeline holds both halves of the app in order -- which is
what makes a race between them legible after the fact, and races between
them are the expensive bug class here.
Redaction runs in the log FORMATTER, not at export time. A credential
sitting in a file on the device is already a disclosure; stripping it on
the way out would be too late. The exporter redacts a second time to
cover files written by builds that predate this. api_key, X-Emby-Token,
Authorization, "AccessToken" and Token="..." all reduce to [REDACTED],
while host, item ids and filenames are deliberately kept -- a log scrubbed
of those is one nobody can debug anything from. Server URLs keep scheme
and host and drop any embedded user:pass@.
Two things the tests caught that review would not have:
- redact_headers recursed on its own output. The replacement keeps the
header NAME, so the next call matched the same header forever; the
test died with a stack overflow. It is a forward scan now.
- The frontend forwarder used `void plugin.error(...)`. `void` discards
a promise's value but not its rejection, so in any webview without
IPC -- a unit test, SSR, a browser preview -- every log line became an
unhandled rejection. 20 of them showed up the first time coverage
ran. Each call now attaches a catch.
Only info and above cross the IPC boundary: debug is per-tick player
state and forwarding it would be thousands of calls a minute for output
nobody reads. A failing forwarder never propagates and never prevents the
console write.
Nothing is transmitted anywhere. The export writes a zip and reports its
path; the user attaches it themselves, which is also what keeps this from
becoming telemetry. An Android share intent is explicitly out of scope --
it is Kotlin work that belongs with the other native code.
The panic hook chains to the previous hook rather than replacing it,
because utils/lock.rs installs a silencing hook around tests that provoke
poisoned locks on purpose.
Spec in docs/specs/diagnostics-and-logging.md; UR-078 / DR-218 / UT-209.
Verified: 1079 frontend tests and the coverage gate, 759 Rust tests,
clippy -D warnings, svelte-check 0 errors, and cargo check for
aarch64-linux-android.
331 lines
12 KiB
Rust
331 lines
12 KiB
Rust
//! Diagnostics: log level control and the exportable bug-report bundle.
|
|
//!
|
|
//! TRACES: UR-078 | DR-218
|
|
//!
|
|
//! The app used to forget everything it did the moment it exited. A user
|
|
//! reporting "the episode randomly restarted" was reporting the symptom of a
|
|
//! race whose evidence had been discarded microseconds later, and the only way
|
|
//! to recover it was to talk them through `adb logcat` — which is how several
|
|
//! bugs in this project's history actually got diagnosed.
|
|
//!
|
|
//! This module is the other half of that: the log is on disk, it survives a
|
|
//! crash, and the user can hand the whole thing over as one file.
|
|
//!
|
|
//! Everything written here has been through [`crate::utils::diagnostics::redact`]
|
|
//! twice — once in the log formatter, and again on the way into the archive, so
|
|
//! files written by a build that predates the formatter pass are covered too.
|
|
|
|
use std::fs;
|
|
use std::io::{Read, Write};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use log::{info, warn, LevelFilter};
|
|
use serde::{Deserialize, Serialize};
|
|
use tauri::{AppHandle, Manager};
|
|
|
|
use crate::utils::diagnostics::{redact, redact_server_url};
|
|
|
|
/// Where an export landed, so the UI can tell the user where to find it.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DiagnosticsBundle {
|
|
/// Absolute path to the written archive.
|
|
pub path: String,
|
|
pub size_bytes: u64,
|
|
/// How many log files went in, excluding the environment summary.
|
|
pub file_count: usize,
|
|
}
|
|
|
|
/// Where logs live and how verbose they currently are.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DiagnosticsInfo {
|
|
/// Directory holding the rotating log files.
|
|
pub log_dir: String,
|
|
/// Active level, lowercase: "error" | "warn" | "info" | "debug" | "trace".
|
|
pub level: String,
|
|
/// Total bytes currently held by log files.
|
|
pub total_size_bytes: u64,
|
|
}
|
|
|
|
/// Name of the file holding the user's chosen level, in the app config dir.
|
|
const LEVEL_FILE: &str = "log-level";
|
|
|
|
/// Parse a stored/user-supplied level name.
|
|
///
|
|
/// Unknown values fall back to Info rather than erroring: this is read at
|
|
/// startup, and a corrupt one-line file must not stop the app from launching.
|
|
pub fn parse_level(raw: &str) -> LevelFilter {
|
|
match raw.trim().to_ascii_lowercase().as_str() {
|
|
"error" => LevelFilter::Error,
|
|
"warn" => LevelFilter::Warn,
|
|
"debug" => LevelFilter::Debug,
|
|
"trace" => LevelFilter::Trace,
|
|
_ => LevelFilter::Info,
|
|
}
|
|
}
|
|
|
|
/// Read the persisted level, if the user has ever set one.
|
|
///
|
|
/// Persisted rather than session-only on purpose: somebody reproducing a bug
|
|
/// needs debug logging to survive *the restart that reproduces it*.
|
|
pub fn stored_level(config_dir: &Path) -> Option<LevelFilter> {
|
|
fs::read_to_string(config_dir.join(LEVEL_FILE))
|
|
.ok()
|
|
.map(|raw| parse_level(&raw))
|
|
}
|
|
|
|
fn level_name(level: LevelFilter) -> &'static str {
|
|
match level {
|
|
LevelFilter::Off => "off",
|
|
LevelFilter::Error => "error",
|
|
LevelFilter::Warn => "warn",
|
|
LevelFilter::Info => "info",
|
|
LevelFilter::Debug => "debug",
|
|
LevelFilter::Trace => "trace",
|
|
}
|
|
}
|
|
|
|
/// Current log level and where the files are.
|
|
///
|
|
/// TRACES: UR-078 | DR-218
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn diagnostics_get_info(app: AppHandle) -> Result<DiagnosticsInfo, String> {
|
|
let log_dir = app
|
|
.path()
|
|
.app_log_dir()
|
|
.map_err(|e| format!("no log directory: {e}"))?;
|
|
|
|
let total_size_bytes = log_files(&log_dir)
|
|
.iter()
|
|
.filter_map(|p| fs::metadata(p).ok())
|
|
.map(|m| m.len())
|
|
.sum();
|
|
|
|
Ok(DiagnosticsInfo {
|
|
log_dir: log_dir.to_string_lossy().to_string(),
|
|
level: level_name(log::max_level()).to_string(),
|
|
total_size_bytes,
|
|
})
|
|
}
|
|
|
|
/// Set the log level, for this session and the next.
|
|
///
|
|
/// TRACES: UR-078 | DR-218
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn diagnostics_set_level(app: AppHandle, level: String) -> Result<String, String> {
|
|
let parsed = parse_level(&level);
|
|
log::set_max_level(parsed);
|
|
|
|
let config_dir = app
|
|
.path()
|
|
.app_config_dir()
|
|
.map_err(|e| format!("no config directory: {e}"))?;
|
|
fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?;
|
|
fs::write(config_dir.join(LEVEL_FILE), level_name(parsed)).map_err(|e| e.to_string())?;
|
|
|
|
info!("[DIAG] log level set to {}", level_name(parsed));
|
|
Ok(level_name(parsed).to_string())
|
|
}
|
|
|
|
/// Collect every log file in the log directory, newest first.
|
|
fn log_files(log_dir: &Path) -> Vec<PathBuf> {
|
|
let Ok(entries) = fs::read_dir(log_dir) else {
|
|
return Vec::new();
|
|
};
|
|
let mut files: Vec<PathBuf> = entries
|
|
.filter_map(|e| e.ok())
|
|
.map(|e| e.path())
|
|
.filter(|p| p.is_file())
|
|
.filter(|p| {
|
|
p.extension()
|
|
.is_some_and(|ext| ext == "log" || ext == "txt")
|
|
})
|
|
.collect();
|
|
files.sort();
|
|
files.reverse();
|
|
files
|
|
}
|
|
|
|
/// A short, non-identifying description of the environment.
|
|
///
|
|
/// Deliberately excludes the token, the username, and any path outside the
|
|
/// app's own directories. The server URL is reduced to scheme and host, which is
|
|
/// diagnostic (https? LAN address? reverse proxy?) without being a credential.
|
|
fn environment_summary(app: &AppHandle, server_url: Option<&str>) -> String {
|
|
let package = app.package_info();
|
|
let mut out = String::new();
|
|
out.push_str("JellyTau diagnostics\n");
|
|
out.push_str("====================\n\n");
|
|
out.push_str(&format!("app version: {}\n", package.version));
|
|
out.push_str(&format!("tauri version: {}\n", tauri::VERSION));
|
|
out.push_str(&format!("os: {}\n", std::env::consts::OS));
|
|
out.push_str(&format!("arch: {}\n", std::env::consts::ARCH));
|
|
out.push_str(&format!("debug build: {}\n", cfg!(debug_assertions)));
|
|
out.push_str(&format!(
|
|
"log level: {}\n",
|
|
level_name(log::max_level())
|
|
));
|
|
out.push_str(&format!(
|
|
"server: {}\n",
|
|
server_url.map_or("not configured".to_string(), redact_server_url)
|
|
));
|
|
out.push_str("\nNo access token, password or username is included in this file.\n");
|
|
out
|
|
}
|
|
|
|
/// Write a redacted diagnostics archive and return where it went.
|
|
///
|
|
/// # Blocking I/O
|
|
///
|
|
/// This reads and rewrites every log file. It is an `async` command so it does
|
|
/// not block the IPC thread, but it must never be called from a player event
|
|
/// callback — see the deadlock note in CLAUDE.md.
|
|
///
|
|
/// TRACES: UR-078 | DR-218
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn diagnostics_export(
|
|
app: AppHandle,
|
|
server_url: Option<String>,
|
|
) -> Result<DiagnosticsBundle, String> {
|
|
let log_dir = app
|
|
.path()
|
|
.app_log_dir()
|
|
.map_err(|e| format!("no log directory: {e}"))?;
|
|
|
|
// Written into the app's own data directory. Choosing an arbitrary
|
|
// user-visible location would need a file dialog on desktop and a storage
|
|
// permission on Android; the UI reports the path and can reveal it.
|
|
let out_dir = app
|
|
.path()
|
|
.app_data_dir()
|
|
.map_err(|e| format!("no data directory: {e}"))?;
|
|
fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?;
|
|
|
|
let archive_path = out_dir.join("jellytau-diagnostics.zip");
|
|
let file = fs::File::create(&archive_path).map_err(|e| e.to_string())?;
|
|
let mut zip = zip::ZipWriter::new(file);
|
|
let options: zip::write::FileOptions<'_, ()> =
|
|
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
|
|
|
let files = log_files(&log_dir);
|
|
let mut written = 0usize;
|
|
|
|
for path in &files {
|
|
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
|
continue;
|
|
};
|
|
|
|
let mut contents = String::new();
|
|
match fs::File::open(path).and_then(|mut f| f.read_to_string(&mut contents)) {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
// A log we cannot read is not a reason to produce no bundle.
|
|
warn!("[DIAG] skipping unreadable log {name}: {e}");
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Second redaction pass. The formatter already cleaned anything this
|
|
// build wrote; this covers files left by an older build.
|
|
let cleaned: String = contents.lines().map(redact).collect::<Vec<_>>().join("\n");
|
|
|
|
zip.start_file(name, options).map_err(|e| e.to_string())?;
|
|
zip.write_all(cleaned.as_bytes())
|
|
.map_err(|e| e.to_string())?;
|
|
written += 1;
|
|
}
|
|
|
|
zip.start_file("environment.txt", options)
|
|
.map_err(|e| e.to_string())?;
|
|
zip.write_all(environment_summary(&app, server_url.as_deref()).as_bytes())
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
zip.finish().map_err(|e| e.to_string())?;
|
|
|
|
let size_bytes = fs::metadata(&archive_path)
|
|
.map_err(|e| e.to_string())?
|
|
.len();
|
|
info!(
|
|
"[DIAG] exported {written} log file(s), {size_bytes} bytes -> {}",
|
|
archive_path.display()
|
|
);
|
|
|
|
Ok(DiagnosticsBundle {
|
|
path: archive_path.to_string_lossy().to_string(),
|
|
size_bytes,
|
|
file_count: written,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_every_level_name_case_insensitively() {
|
|
assert_eq!(parse_level("debug"), LevelFilter::Debug);
|
|
assert_eq!(parse_level("DEBUG"), LevelFilter::Debug);
|
|
assert_eq!(parse_level(" warn\n"), LevelFilter::Warn);
|
|
assert_eq!(parse_level("error"), LevelFilter::Error);
|
|
assert_eq!(parse_level("trace"), LevelFilter::Trace);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_level_falls_back_to_info_rather_than_failing() {
|
|
// This is read at startup from a file on disk. A corrupt value must not
|
|
// stop the app launching.
|
|
assert_eq!(parse_level("banana"), LevelFilter::Info);
|
|
assert_eq!(parse_level(""), LevelFilter::Info);
|
|
}
|
|
|
|
#[test]
|
|
fn level_names_round_trip() {
|
|
for name in ["error", "warn", "info", "debug", "trace"] {
|
|
assert_eq!(level_name(parse_level(name)), name);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn stored_level_is_none_when_never_set() {
|
|
let dir = std::env::temp_dir().join("jellytau-diag-test-empty");
|
|
let _ = fs::create_dir_all(&dir);
|
|
let _ = fs::remove_file(dir.join(LEVEL_FILE));
|
|
assert!(stored_level(&dir).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn stored_level_reads_back_what_was_written() {
|
|
let dir = std::env::temp_dir().join("jellytau-diag-test-roundtrip");
|
|
fs::create_dir_all(&dir).unwrap();
|
|
fs::write(dir.join(LEVEL_FILE), "debug").unwrap();
|
|
assert_eq!(stored_level(&dir), Some(LevelFilter::Debug));
|
|
let _ = fs::remove_file(dir.join(LEVEL_FILE));
|
|
}
|
|
|
|
#[test]
|
|
fn log_files_ignores_non_log_files() {
|
|
let dir = std::env::temp_dir().join("jellytau-diag-test-listing");
|
|
fs::create_dir_all(&dir).unwrap();
|
|
fs::write(dir.join("jellytau.log"), "x").unwrap();
|
|
fs::write(dir.join("notes.md"), "x").unwrap();
|
|
fs::write(dir.join("jellytau.zip"), "x").unwrap();
|
|
|
|
let found = log_files(&dir);
|
|
let names: Vec<String> = found
|
|
.iter()
|
|
.filter_map(|p| p.file_name()?.to_str().map(String::from))
|
|
.collect();
|
|
|
|
assert!(names.contains(&"jellytau.log".to_string()));
|
|
// The export archive itself lives elsewhere, but never re-zip a zip.
|
|
assert!(!names.contains(&"jellytau.zip".to_string()));
|
|
assert!(!names.contains(&"notes.md".to_string()));
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
}
|