diff --git a/package.json b/package.json index 20bbe567..56a1bf51 100644 --- a/package.json +++ b/package.json @@ -14,13 +14,14 @@ }, "type": "module", "scripts": { + "lint:console": "node scripts/check-no-console.mjs", "version-sync": "node scripts/sync-version.mjs", "dev": "vite", "build": "tsc && vite build", "release": "node scripts/sync-version.mjs --commit && tsc && vite build && tauri build", "preview": "vite preview", "tauri": "tauri", - "lint": "biome check src/", + "lint": "node scripts/check-no-console.mjs && biome check src/", "format": "biome check --write src/", "format:check": "biome check --check src/", "i18n:check": "prettier --check src/i18n/locales/*.json", @@ -72,4 +73,4 @@ "typescript": "~5.8.3", "vite": "^7.0.4" } -} \ No newline at end of file +} diff --git a/scripts/check-no-console.mjs b/scripts/check-no-console.mjs new file mode 100644 index 00000000..2144c096 --- /dev/null +++ b/scripts/check-no-console.mjs @@ -0,0 +1,50 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; + +const ROOT = process.cwd(); +const SRC_DIR = path.join(ROOT, "src"); +const ALLOWED_FILE = path.normalize(path.join(SRC_DIR, "lib", "logger.ts")); +const FILE_EXTENSIONS = new Set([".ts", ".tsx"]); +const CONSOLE_PATTERN = /\bconsole\.(debug|info|warn|error|log)\s*\(/g; + +const violations = []; + +async function walk(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + + for (const entry of entries) { + const resolved = path.join(directory, entry.name); + if (entry.isDirectory()) { + await walk(resolved); + continue; + } + + if (!FILE_EXTENSIONS.has(path.extname(entry.name))) { + continue; + } + + if (path.normalize(resolved) === ALLOWED_FILE) { + continue; + } + + const content = await readFile(resolved, "utf8"); + const lines = content.split(/\r?\n/); + + lines.forEach((line, index) => { + if (CONSOLE_PATTERN.test(line)) { + violations.push(`${path.relative(ROOT, resolved)}:${index + 1}: ${line.trim()}`); + } + CONSOLE_PATTERN.lastIndex = 0; + }); + } +} + +await walk(SRC_DIR); + +if (violations.length > 0) { + console.error("Unexpected console.* usage outside src/lib/logger.ts:"); + for (const violation of violations) { + console.error(` ${violation}`); + } + process.exitCode = 1; +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 57f01287..e628506c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -7108,6 +7108,16 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.22" @@ -7118,6 +7128,8 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", @@ -7125,6 +7137,7 @@ dependencies = [ "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a7963161..1f559d5d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -52,7 +52,7 @@ uuid = { version = "1", features = ["v4"] } async-trait = "0.1" thiserror = "2" tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "local-time"] } +tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "local-time"] } time = { version = "0.3", features = ["formatting", "local-offset", "macros"] } tracing-appender = "0.2" nucleo-matcher = "0.3" diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index 91a293a3..5286a3a7 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -1,50 +1,8 @@ use std::sync::Arc; use tauri::Manager; -use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; use crate::core::SessionManager; -pub fn init_tracing(log_dir: std::path::PathBuf) { - let _ = std::fs::create_dir_all(&log_dir); - - let file_appender = tracing_appender::rolling::Builder::new() - .rotation(tracing_appender::rolling::Rotation::DAILY) - .filename_prefix("dragonfly") - .filename_suffix("log") - .max_log_files(7) - .build(&log_dir) - .expect("failed to initialize rolling file appender"); - - let filter = EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("dragonfly=info,user_action=debug,warn")); - - let local_time = fmt::time::OffsetTime::local_rfc_3339().unwrap_or_else(|_| { - fmt::time::OffsetTime::new( - time::UtcOffset::UTC, - time::format_description::well_known::Rfc3339, - ) - }); - - tracing_subscriber::registry() - .with(filter) - .with( - fmt::layer() - .with_writer(file_appender) - .with_ansi(false) - .with_target(true) - .with_timer(local_time.clone()), - ) - .with( - fmt::layer() - .with_writer(std::io::stderr) - .compact() - .with_timer(local_time), - ) - .init(); - - tracing::info!("Dragonfly starting"); -} - pub fn setup( app: &mut tauri::App, session_manager: Arc, @@ -54,8 +12,26 @@ pub fn setup( .home_dir() .map_err(|e: tauri::Error| e.to_string())?; + let settings_load = crate::config::load_app_settings(app.handle()); + let diagnostics = settings_load + .as_ref() + .map(|settings| settings.diagnostics.clone()) + .unwrap_or_default(); let log_dir = app.path().app_log_dir().map_err(|e| e.to_string())?; - init_tracing(log_dir); + crate::observability::init_tracing(log_dir, &diagnostics); + + if let Err(error) = settings_load { + crate::observability::log_event(crate::observability::StructuredLog { + level: crate::observability::StructuredLogLevel::Warn, + domain: "settings.persistence".to_string(), + event: "settings.load_failed".to_string(), + message: "Failed to load app settings before tracing initialization".to_string(), + ids: None, + data: None, + error: Some(serde_json::json!({ "message": error.to_string() })), + client_timestamp: None, + }); + } session_manager.set_app_handle(app.handle().clone()); diff --git a/src-tauri/src/cmd/log.rs b/src-tauri/src/cmd/log.rs index 4f7dcfd8..73b38e07 100644 --- a/src-tauri/src/cmd/log.rs +++ b/src-tauri/src/cmd/log.rs @@ -1,44 +1,29 @@ -use serde::Deserialize; +use std::sync::Arc; +use crate::core::ssh::TunnelManager; +use crate::core::SessionManager; use crate::error::AppResult; +use crate::observability::{self, FrontendLogEntry}; -#[derive(Clone, Copy, Debug, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum LogLevel { - Debug, - Info, - Warn, - Error, +#[tauri::command] +pub fn append_frontend_logs(entries: Vec) -> AppResult<()> { + for entry in entries { + observability::log_event(observability::frontend_log_to_structured(entry)); + } + Ok(()) } #[tauri::command] -pub fn write_log(level: LogLevel, message: String, context: Option) -> AppResult<()> { - match (level, context.as_deref()) { - (LogLevel::Debug, Some(context)) => { - tracing::debug!(source = "frontend", context = %context, "{message}"); - } - (LogLevel::Debug, None) => { - tracing::debug!(source = "frontend", "{message}"); - } - (LogLevel::Info, Some(context)) => { - tracing::info!(source = "frontend", context = %context, "{message}"); - } - (LogLevel::Info, None) => { - tracing::info!(source = "frontend", "{message}"); - } - (LogLevel::Warn, Some(context)) => { - tracing::warn!(source = "frontend", context = %context, "{message}"); - } - (LogLevel::Warn, None) => { - tracing::warn!(source = "frontend", "{message}"); - } - (LogLevel::Error, Some(context)) => { - tracing::error!(source = "frontend", context = %context, "{message}"); - } - (LogLevel::Error, None) => { - tracing::error!(source = "frontend", "{message}"); - } - } - - Ok(()) +pub fn export_diagnostics( + app: tauri::AppHandle, + session_manager: tauri::State<'_, Arc>, + tunnel_manager: tauri::State<'_, Arc>, + output_path: String, +) -> AppResult<()> { + observability::export_diagnostics( + &app, + session_manager.inner().as_ref(), + tunnel_manager.inner().as_ref(), + &output_path, + ) } diff --git a/src-tauri/src/cmd/session.rs b/src-tauri/src/cmd/session.rs index bdb8f94b..4c1ee95e 100644 --- a/src-tauri/src/cmd/session.rs +++ b/src-tauri/src/cmd/session.rs @@ -2,6 +2,7 @@ use crate::config; use crate::core::ssh::{self, PendingAuthManager}; use crate::core::{self, RecordingManager, SessionCommand, SessionInfo, SessionManager}; use crate::error::{AppError, AppResult}; +use crate::observability::{self, StructuredLog, StructuredLogLevel}; use crate::utils::fuzzy::{fuzzy_search_items, FuzzyResult}; use std::sync::Arc; use tauri::Manager; @@ -169,6 +170,17 @@ pub async fn close_session( ) -> AppResult<()> { let session_id_clone = session_id.clone(); + observability::log_event(StructuredLog { + level: StructuredLogLevel::Info, + domain: "session.lifecycle".to_string(), + event: "session.close_requested".to_string(), + message: "Closing session".to_string(), + ids: Some(serde_json::json!({ "session_id": session_id.clone() })), + data: None, + error: None, + client_timestamp: None, + }); + let res = match state.send_command(&session_id, SessionCommand::Close).await { Err(AppError::SessionNotFound(_)) => Ok(()), other => other, @@ -180,16 +192,31 @@ pub async fn close_session( let session_temp_dir = temp_dir.join("dragonfly").join(&session_id_clone); if session_temp_dir.exists() { if let Err(e) = tokio::fs::remove_dir_all(&session_temp_dir).await { - tracing::warn!( - "Failed to clean up temp directory {}: {}", - session_temp_dir.display(), - e - ); + observability::log_event(StructuredLog { + level: StructuredLogLevel::Warn, + domain: "session.lifecycle".to_string(), + event: "session.temp_cleanup_failed".to_string(), + message: "Failed to clean up session temp directory".to_string(), + ids: Some(serde_json::json!({ "session_id": session_id_clone })), + data: Some(serde_json::json!({ + "temp_dir": session_temp_dir, + })), + error: Some(serde_json::json!({ "message": e.to_string() })), + client_timestamp: None, + }); } else { - tracing::info!( - "Successfully cleaned up temp directory for session: {}", - session_id_clone - ); + observability::log_event(StructuredLog { + level: StructuredLogLevel::Info, + domain: "session.lifecycle".to_string(), + event: "session.temp_cleanup_succeeded".to_string(), + message: "Cleaned up session temp directory".to_string(), + ids: Some(serde_json::json!({ "session_id": session_id_clone })), + data: Some(serde_json::json!({ + "temp_dir": session_temp_dir, + })), + error: None, + client_timestamp: None, + }); } } } @@ -300,8 +327,28 @@ pub async fn submit_otp_response( responses: Vec, ) -> AppResult<()> { if state.respond(&request_id, Some(responses)).await { + observability::log_event(StructuredLog { + level: StructuredLogLevel::Info, + domain: "security.flow".to_string(), + event: "otp.response_received".to_string(), + message: "Received OTP response from frontend".to_string(), + ids: Some(serde_json::json!({ "request_id": request_id })), + data: None, + error: None, + client_timestamp: None, + }); Ok(()) } else { + observability::log_event(StructuredLog { + level: StructuredLogLevel::Warn, + domain: "security.flow".to_string(), + event: "otp.response_rejected".to_string(), + message: "Rejected OTP response for missing request".to_string(), + ids: Some(serde_json::json!({ "request_id": request_id.clone() })), + data: None, + error: None, + client_timestamp: None, + }); Err(AppError::Auth(format!( "No pending OTP request with id '{}'", request_id @@ -314,6 +361,28 @@ pub async fn cancel_otp_request( state: tauri::State<'_, Arc>, request_id: String, ) -> AppResult<()> { - state.respond(&request_id, None).await; + let cancelled = state.respond(&request_id, None).await; + observability::log_event(StructuredLog { + level: if cancelled { + StructuredLogLevel::Info + } else { + StructuredLogLevel::Warn + }, + domain: "security.flow".to_string(), + event: if cancelled { + "otp.request_cancelled".to_string() + } else { + "otp.request_cancel_missing".to_string() + }, + message: if cancelled { + "Cancelled OTP request".to_string() + } else { + "OTP request was already missing when cancellation arrived".to_string() + }, + ids: Some(serde_json::json!({ "request_id": request_id })), + data: None, + error: None, + client_timestamp: None, + }); Ok(()) } diff --git a/src-tauri/src/cmd/settings.rs b/src-tauri/src/cmd/settings.rs index 44468aa2..541934d3 100644 --- a/src-tauri/src/cmd/settings.rs +++ b/src-tauri/src/cmd/settings.rs @@ -1,5 +1,6 @@ use crate::config; use crate::error::AppResult; +use crate::observability::{self, StructuredLog, StructuredLogLevel}; use crate::utils::crypto; #[tauri::command] @@ -27,7 +28,22 @@ pub fn save_app_settings( app: tauri::AppHandle, mut settings: config::AppSettings, ) -> AppResult<()> { - let existing = config::load_app_settings(&app)?; + let existing = match config::load_app_settings(&app) { + Ok(existing) => existing, + Err(error) => { + observability::log_event(StructuredLog { + level: StructuredLogLevel::Error, + domain: "settings.persistence".to_string(), + event: "settings.load_failed".to_string(), + message: "Failed to load existing app settings before save".to_string(), + ids: None, + data: None, + error: Some(serde_json::json!({ "message": error.to_string() })), + client_timestamp: None, + }); + return Err(error); + } + }; match settings.security.master_password.as_deref() { Some("__SET__") => { @@ -56,7 +72,24 @@ pub fn save_app_settings( settings.security.master_password = Some(crypto::encrypt_settings_secret(plain)?); } } - config::save_app_settings(&app, &settings) + if let Err(error) = config::save_app_settings(&app, &settings) { + observability::log_event(StructuredLog { + level: StructuredLogLevel::Error, + domain: "settings.persistence".to_string(), + event: "settings.save_failed".to_string(), + message: "Failed to persist app settings".to_string(), + ids: None, + data: Some(serde_json::json!({ + "diagnostics_level": settings.diagnostics.level.as_str(), + "diagnostics_retention_days": settings.diagnostics.retention_days, + })), + error: Some(serde_json::json!({ "message": error.to_string() })), + client_timestamp: None, + }); + return Err(error); + } + + Ok(()) } #[tauri::command] diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index 7dbc0639..55b4d42b 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -35,9 +35,9 @@ pub use quick_command::{ #[allow(unused_imports)] pub use settings::{ load_app_settings, save_app_settings, ActionLinksMatcherSettings, AppSettings, - AppearanceSettings, GeneralSettings, InteractionSettings, KeywordHighlightRule, ProxySettings, - SearchEngine, SearchSettings, SecuritySettings, TerminalSettings, TransferSettings, - TranslationSettings, + AppearanceSettings, DiagnosticsLogLevel, DiagnosticsSettings, GeneralSettings, + InteractionSettings, KeywordHighlightRule, ProxySettings, SearchEngine, SearchSettings, + SecuritySettings, TerminalSettings, TransferSettings, TranslationSettings, }; #[allow(unused_imports)] pub use tunnel::{load_tunnels, save_tunnels, TunnelConfig, TunnelsConfig}; diff --git a/src-tauri/src/config/settings/diagnostics.rs b/src-tauri/src/config/settings/diagnostics.rs new file mode 100644 index 00000000..9f3ecca7 --- /dev/null +++ b/src-tauri/src/config/settings/diagnostics.rs @@ -0,0 +1,46 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum DiagnosticsLogLevel { + Warn, + Info, + Debug, +} + +impl Default for DiagnosticsLogLevel { + fn default() -> Self { + Self::Info + } +} + +impl DiagnosticsLogLevel { + pub const fn as_str(self) -> &'static str { + match self { + Self::Warn => "warn", + Self::Info => "info", + Self::Debug => "debug", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiagnosticsSettings { + #[serde(default)] + pub level: DiagnosticsLogLevel, + #[serde(default = "default_retention_days")] + pub retention_days: u32, +} + +const fn default_retention_days() -> u32 { + 7 +} + +impl Default for DiagnosticsSettings { + fn default() -> Self { + Self { + level: DiagnosticsLogLevel::Info, + retention_days: default_retention_days(), + } + } +} diff --git a/src-tauri/src/config/settings/mod.rs b/src-tauri/src/config/settings/mod.rs index ee536c36..8052ced4 100644 --- a/src-tauri/src/config/settings/mod.rs +++ b/src-tauri/src/config/settings/mod.rs @@ -1,4 +1,5 @@ mod appearance; +mod diagnostics; mod general; mod interaction; mod proxy; @@ -9,6 +10,7 @@ mod transfer; mod translation; pub use appearance::AppearanceSettings; +pub use diagnostics::{DiagnosticsLogLevel, DiagnosticsSettings}; pub use general::GeneralSettings; pub use interaction::InteractionSettings; pub use proxy::ProxySettings; @@ -45,6 +47,8 @@ pub struct AppSettings { #[serde(default)] pub transfer: TransferSettings, #[serde(default)] + pub diagnostics: DiagnosticsSettings, + #[serde(default)] pub ui: UiConfig, } diff --git a/src-tauri/src/core/serial.rs b/src-tauri/src/core/serial.rs index c4ab573a..39755d6b 100644 --- a/src-tauri/src/core/serial.rs +++ b/src-tauri/src/core/serial.rs @@ -5,6 +5,7 @@ use super::session::{ }; use crate::core::SessionOutputCoalescer; use crate::error::{AppError, AppResult}; +use crate::observability::{log_event, log_rate_limited, StructuredLog, StructuredLogLevel}; use serialport::{DataBits, FlowControl, Parity, StopBits}; use std::io::{Read, Write}; use std::sync::{Arc, Mutex}; @@ -59,13 +60,24 @@ pub async fn create_serial_session( app: AppHandle, manager: Arc, config: SerialConfig, - _connection_id: Option, + connection_id: Option, ) -> AppResult { - tracing::info!( - "Creating serial session on {} @ {} baud", - config.port_name, - config.baud_rate - ); + log_event(StructuredLog { + level: StructuredLogLevel::Info, + domain: "session.lifecycle".to_string(), + event: "session.create_start".to_string(), + message: "Creating serial session".to_string(), + ids: connection_id + .as_ref() + .map(|value| serde_json::json!({ "connection_id": value })), + data: Some(serde_json::json!({ + "session_type": "Serial", + "port_name": config.port_name.clone(), + "baud_rate": config.baud_rate, + })), + error: None, + client_timestamp: None, + }); let session_id = uuid::Uuid::new_v4().to_string(); let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::(); @@ -93,7 +105,7 @@ pub async fn create_serial_session( let rt_handle = tokio::runtime::Handle::current(); std::thread::spawn(move || { - serial_session_thread(app, sid, mgr, cmd_rx, rt_handle, config); + serial_session_thread(app, sid, mgr, cmd_rx, rt_handle, config, connection_id); }); Ok(session_id) @@ -106,6 +118,7 @@ fn serial_session_thread( mut cmd_rx: mpsc::UnboundedReceiver, rt_handle: tokio::runtime::Handle, config: SerialConfig, + connection_id: Option, ) { let port = match serialport::new(&config.port_name, config.baud_rate) .data_bits(parse_data_bits(config.data_bits)) @@ -117,7 +130,23 @@ fn serial_session_thread( { Ok(p) => p, Err(e) => { - tracing::error!("Failed to open serial port: {}", e); + log_event(StructuredLog { + level: StructuredLogLevel::Error, + domain: "session.lifecycle".to_string(), + event: "session.connection_failed".to_string(), + message: "Failed to open serial port".to_string(), + ids: Some(serde_json::json!({ + "session_id": session_id.clone(), + "connection_id": connection_id.clone(), + })), + data: Some(serde_json::json!({ + "session_type": "Serial", + "port_name": config.port_name.clone(), + "baud_rate": config.baud_rate, + })), + error: Some(serde_json::json!({ "message": e.to_string() })), + client_timestamp: None, + }); let _ = app.emit( &format!("session-error-{}", session_id), format!("Failed to open serial port: {}", e), @@ -159,7 +188,22 @@ fn serial_session_thread( continue; } Err(e) => { - tracing::warn!("Serial read error: {}", e); + log_rate_limited(StructuredLog { + level: StructuredLogLevel::Warn, + domain: "session.lifecycle".to_string(), + event: "serial.read_error".to_string(), + message: "Serial read error".to_string(), + ids: Some(serde_json::json!({ + "session_id": sid_reader.clone(), + "connection_id": connection_id.clone(), + })), + data: Some(serde_json::json!({ + "session_type": "Serial", + "port_name": config.port_name.clone(), + })), + error: Some(serde_json::json!({ "message": e.to_string() })), + client_timestamp: None, + }); break; } } diff --git a/src-tauri/src/core/ssh/auth.rs b/src-tauri/src/core/ssh/auth.rs index c8d883b1..1b92f121 100644 --- a/src-tauri/src/core/ssh/auth.rs +++ b/src-tauri/src/core/ssh/auth.rs @@ -1,8 +1,10 @@ use super::client::{SshAuth, SshConfig, SshHandler}; use crate::error::{AppError, AppResult}; +use crate::observability::{self, StructuredLog, StructuredLogLevel}; use russh::client::{self, KeyboardInteractiveAuthResponse}; use russh::MethodKind; use serde::Serialize; +use serde_json::{json, Map, Value}; use std::collections::HashMap; use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager}; @@ -50,6 +52,46 @@ struct OtpRequestPayload { otp_entry_id: Option, } +fn build_ids(connection_id: Option<&str>, request_id: Option<&str>) -> Option { + let mut ids = Map::new(); + if let Some(connection_id) = connection_id { + ids.insert( + "connection_id".to_string(), + Value::String(connection_id.to_string()), + ); + } + if let Some(request_id) = request_id { + ids.insert("request_id".to_string(), Value::String(request_id.to_string())); + } + if ids.is_empty() { + None + } else { + Some(Value::Object(ids)) + } +} + +fn log_structured( + level: StructuredLogLevel, + domain: &str, + event: &str, + message: &str, + connection_id: Option<&str>, + request_id: Option<&str>, + data: Option, + error: Option, +) { + observability::log_event(StructuredLog { + level, + domain: domain.to_string(), + event: event.to_string(), + message: message.to_string(), + ids: build_ids(connection_id, request_id), + data, + error, + client_timestamp: None, + }); +} + pub(crate) fn load_saved_ssh_config(app: &AppHandle, connection_id: &str) -> AppResult { let conn = crate::config::load_connection_by_id(app, connection_id)?; resolve_saved_ssh_config(app, &conn, Some(connection_id.to_string()), true) @@ -216,12 +258,20 @@ pub(super) async fn authenticate_handle( match &config.auth { SshAuth::Password { password } => { - tracing::info!( - host = %config.host, - port = config.port, - user = %config.username, - auth_mode = "password", - "Starting SSH authentication" + log_structured( + StructuredLogLevel::Info, + "ssh.auth", + "auth.start", + "Starting SSH authentication", + config.connection_id.as_deref(), + None, + Some(json!({ + "host": config.host, + "port": config.port, + "username": config.username, + "auth_mode": "password", + })), + None, ); let authenticated = handle @@ -233,6 +283,7 @@ pub(super) async fn authenticate_handle( handle, &authenticated, &config.username, + config.connection_id.as_deref(), &config.name, app, password_error, @@ -253,14 +304,22 @@ pub(super) async fn authenticate_handle( .flatten() .flatten(); - tracing::info!( - host = %config.host, - port = config.port, - user = %config.username, - auth_mode = "publickey", - key_algorithm = %key.algorithm(), - rsa_hash = ?hash_alg, - "Starting SSH authentication" + log_structured( + StructuredLogLevel::Info, + "ssh.auth", + "auth.start", + "Starting SSH authentication", + config.connection_id.as_deref(), + None, + Some(json!({ + "host": config.host, + "port": config.port, + "username": config.username, + "auth_mode": "publickey", + "key_algorithm": key.algorithm().to_string(), + "rsa_hash": format!("{hash_alg:?}"), + })), + None, ); let authenticated = handle @@ -275,6 +334,7 @@ pub(super) async fn authenticate_handle( handle, &authenticated, &config.username, + config.connection_id.as_deref(), &config.name, app, key_error, @@ -285,11 +345,19 @@ pub(super) async fn authenticate_handle( } } - tracing::info!( - host = %config.host, - port = config.port, - user = %config.username, - "SSH authentication succeeded" + log_structured( + StructuredLogLevel::Info, + "ssh.auth", + "auth.succeeded", + "SSH authentication succeeded", + config.connection_id.as_deref(), + None, + Some(json!({ + "host": config.host, + "port": config.port, + "username": config.username, + })), + None, ); Ok(()) @@ -341,6 +409,7 @@ fn resolve_otp_info(app: &AppHandle, connection_id: &str) -> Option, username: &str, + connection_id: Option<&str>, connection_name: &str, app: &AppHandle, mode: KeyboardInteractiveMode<'_>, @@ -351,11 +420,18 @@ async fn finish_keyboard_interactive( .ok_or_else(|| AppError::Auth("PendingAuthManager not available".to_string()))?; let pending_mgr = pending_mgr.inner().clone(); - tracing::info!( - connection_name, - username, - mode = mode.label(), - "Starting keyboard-interactive authentication" + log_structured( + StructuredLogLevel::Info, + "ssh.auth", + "keyboard_interactive.start", + "Starting keyboard-interactive authentication", + connection_id, + None, + Some(json!({ + "username": username, + "mode": mode.label(), + })), + None, ); let mut step = handle @@ -366,11 +442,18 @@ async fn finish_keyboard_interactive( loop { match step { KeyboardInteractiveAuthResponse::Success => { - tracing::info!( - connection_name, - username, - mode = mode.label(), - "Keyboard-interactive authentication succeeded" + log_structured( + StructuredLogLevel::Info, + "ssh.auth", + "keyboard_interactive.succeeded", + "Keyboard-interactive authentication succeeded", + connection_id, + None, + Some(json!({ + "username": username, + "mode": mode.label(), + })), + None, ); return Ok(()); } @@ -378,13 +461,20 @@ async fn finish_keyboard_interactive( remaining_methods, partial_success, } => { - tracing::warn!( - connection_name, - username, - mode = mode.label(), - ?remaining_methods, - partial_success, - "Keyboard-interactive authentication failed" + log_structured( + StructuredLogLevel::Warn, + "ssh.auth", + "keyboard_interactive.failed", + "Keyboard-interactive authentication failed", + connection_id, + None, + Some(json!({ + "username": username, + "mode": mode.label(), + "remaining_methods": format!("{remaining_methods:?}"), + "partial_success": partial_success, + })), + None, ); return Err(AppError::Auth( "Keyboard-interactive authentication failed".to_string(), @@ -396,13 +486,20 @@ async fn finish_keyboard_interactive( prompts, } => { let hidden_prompts = prompts.iter().filter(|prompt| !prompt.echo).count(); - tracing::debug!( - connection_name, - username, - mode = mode.label(), - prompt_count = prompts.len(), - hidden_prompts, - "Received keyboard-interactive prompts" + log_structured( + StructuredLogLevel::Debug, + "ssh.auth", + "keyboard_interactive.prompts_received", + "Received keyboard-interactive prompts", + connection_id, + None, + Some(json!({ + "username": username, + "mode": mode.label(), + "prompt_count": prompts.len(), + "hidden_prompts": hidden_prompts, + })), + None, ); let responses = if prompts.is_empty() { @@ -411,18 +508,34 @@ async fn finish_keyboard_interactive( .password() .filter(|_| should_auto_fill_password_prompts(&prompts)) { - tracing::info!( - connection_name, - username, - "Auto-filling password for keyboard-interactive auth" + log_structured( + StructuredLogLevel::Info, + "security.flow", + "keyboard_interactive.password_autofill", + "Auto-filling password for keyboard-interactive auth", + connection_id, + None, + Some(json!({ + "username": username, + "prompt_count": prompts.len(), + })), + None, ); vec![password.to_string()] } else if let Some(info) = otp_info.filter(|i| i.auto_fill) { - tracing::info!( - connection_name, - username, - otp_entry_id = %info.otp_id, - "Auto-filling OTP for keyboard-interactive auth" + log_structured( + StructuredLogLevel::Info, + "security.flow", + "otp.auto_fill", + "Auto-filling OTP for keyboard-interactive auth", + connection_id, + None, + Some(json!({ + "username": username, + "otp_entry_id": info.otp_id, + "prompt_count": prompts.len(), + })), + None, ); let result = crate::cmd::otp::generate_otp_for_entry(app, &info.otp_id)?; prompts.iter().map(|_| result.code.clone()).collect() @@ -442,12 +555,19 @@ async fn finish_keyboard_interactive( .collect(), otp_entry_id: otp_info.map(|i| i.otp_id.clone()), }; - tracing::info!( - connection_name, - username, - prompt_count = payload.prompts.len(), - otp_entry_id = payload.otp_entry_id.as_deref(), - "Forwarding keyboard-interactive prompts to frontend" + log_structured( + StructuredLogLevel::Info, + "security.flow", + "otp.requested", + "Forwarding keyboard-interactive prompts to frontend", + connection_id, + Some(&request_id), + Some(json!({ + "username": username, + "prompt_count": payload.prompts.len(), + "otp_entry_id": payload.otp_entry_id, + })), + None, ); let _ = app.emit("otp-request", &payload); @@ -483,6 +603,7 @@ async fn try_keyboard_interactive_after_partial( handle: &mut client::Handle, auth_result: &client::AuthResult, username: &str, + connection_id: Option<&str>, connection_name: &str, app: &AppHandle, fallback_error: &str, @@ -501,15 +622,23 @@ async fn try_keyboard_interactive_after_partial( keyboard_interactive_available && password_fallback.is_some(); if *partial_success && keyboard_interactive_available { - tracing::info!( - connection_name, - username, - ?remaining_methods, - "Primary auth partial success, continuing with keyboard-interactive" + log_structured( + StructuredLogLevel::Info, + "ssh.auth", + "auth.partial_success", + "Primary auth partial success, continuing with keyboard-interactive", + connection_id, + None, + Some(json!({ + "username": username, + "remaining_methods": format!("{remaining_methods:?}"), + })), + None, ); finish_keyboard_interactive( handle, username, + connection_id, connection_name, app, KeyboardInteractiveMode::AdditionalFactor, @@ -517,24 +646,48 @@ async fn try_keyboard_interactive_after_partial( ) .await } else if can_retry_with_password_fallback { - tracing::info!( - connection_name, - username, - ?remaining_methods, - "Password auth rejected, retrying with keyboard-interactive" + log_structured( + StructuredLogLevel::Info, + "ssh.auth", + "auth.password_fallback", + "Password auth rejected, retrying with keyboard-interactive", + connection_id, + None, + Some(json!({ + "username": username, + "remaining_methods": format!("{remaining_methods:?}"), + })), + None, ); let Some(mode) = password_fallback else { return Err(AppError::Auth(fallback_error.to_string())); }; - finish_keyboard_interactive(handle, username, connection_name, app, mode, otp_info) + finish_keyboard_interactive( + handle, + username, + connection_id, + connection_name, + app, + mode, + otp_info, + ) .await } else { - tracing::warn!( - connection_name, - username, - ?remaining_methods, - partial_success = *partial_success, - "SSH authentication failed without usable keyboard-interactive fallback" + log_structured( + StructuredLogLevel::Warn, + "ssh.auth", + "auth.failed", + "SSH authentication failed without usable keyboard-interactive fallback", + connection_id, + None, + Some(json!({ + "username": username, + "remaining_methods": format!("{remaining_methods:?}"), + "partial_success": partial_success, + })), + Some(json!({ + "message": fallback_error, + })), ); Err(AppError::Auth(fallback_error.to_string())) } diff --git a/src-tauri/src/core/ssh/session.rs b/src-tauri/src/core/ssh/session.rs index a44d1ed2..c358e5cc 100644 --- a/src-tauri/src/core/ssh/session.rs +++ b/src-tauri/src/core/ssh/session.rs @@ -30,14 +30,9 @@ async fn create_authenticated_connection( let jump_handler = SshHandler::new(app.clone(), jump_config.host.clone(), jump_config.port); let mut jump_handle = connect_with_proxy(jump_config, ssh_client_config.clone(), jump_handler).await?; - let jump_password_error = format!( - "Authentication failed for jump host '{}': invalid credentials", - jump_config.name - ); - let jump_key_error = format!( - "Authentication failed for jump host '{}': key rejected", - jump_config.name - ); + let jump_password_error = + "Authentication failed for jump host: invalid credentials".to_string(); + let jump_key_error = "Authentication failed for jump host: key rejected".to_string(); authenticate_handle( &mut jump_handle, jump_config, diff --git a/src-tauri/src/core/ssh/sftp.rs b/src-tauri/src/core/ssh/sftp.rs index 0d7699cc..c95b260f 100644 --- a/src-tauri/src/core/ssh/sftp.rs +++ b/src-tauri/src/core/ssh/sftp.rs @@ -6,6 +6,7 @@ use super::SshConnectionHandles; use crate::core::SessionManager; use crate::error::{AppError, AppResult}; +use crate::observability::{log_event, StructuredLog, StructuredLogLevel}; use russh_sftp::client::SftpSession; use russh_sftp::protocol::{FileAttributes, FileType, OpenFlags}; use serde::Serialize; @@ -200,6 +201,10 @@ fn find_transfer(id: &str) -> Option> { ACTIVE_TRANSFERS.lock().unwrap().get(id).cloned() } +pub(crate) fn active_transfer_count() -> usize { + ACTIVE_TRANSFERS.lock().unwrap().len() +} + async fn wait_for_transfer_ready(controller: &Arc) -> AppResult<()> { loop { let notified = controller.notify.notified(); @@ -567,7 +572,20 @@ pub async fn download_remote_file( let mut last_err = None; for attempt in 0..=max_retries { if attempt > 0 { - tracing::info!(attempt, "Retrying download of {}", remote_path); + log_event(StructuredLog { + level: StructuredLogLevel::Info, + domain: "transfer.lifecycle".to_string(), + event: "transfer.retry".to_string(), + message: "Retrying download".to_string(), + ids: Some(serde_json::json!({ "session_id": session_id })), + data: Some(serde_json::json!({ + "direction": "download", + "attempt": attempt, + "remote_path": remote_path, + })), + error: None, + client_timestamp: None, + }); } match download_remote_file_inner( &app, @@ -896,7 +914,20 @@ pub async fn upload_local_file( let mut last_err = None; for attempt in 0..=max_retries { if attempt > 0 { - tracing::info!(attempt, "Retrying upload of {}", local_path); + log_event(StructuredLog { + level: StructuredLogLevel::Info, + domain: "transfer.lifecycle".to_string(), + event: "transfer.retry".to_string(), + message: "Retrying upload".to_string(), + ids: Some(serde_json::json!({ "session_id": session_id })), + data: Some(serde_json::json!({ + "direction": "upload", + "attempt": attempt, + "local_path": local_path, + })), + error: None, + client_timestamp: None, + }); } match upload_local_file_inner( &app, diff --git a/src-tauri/src/core/ssh/tunnel.rs b/src-tauri/src/core/ssh/tunnel.rs index 5b3558c8..5227966a 100644 --- a/src-tauri/src/core/ssh/tunnel.rs +++ b/src-tauri/src/core/ssh/tunnel.rs @@ -3,6 +3,7 @@ use super::{create_ssh_handle, SshHandle, SshRawHandle}; use crate::config::{self, TunnelConfig}; use crate::error::{AppError, AppResult}; +use crate::observability::{log_event, log_rate_limited, StructuredLog, StructuredLogLevel}; use std::collections::HashMap; use std::sync::Arc; use tauri::AppHandle; @@ -30,6 +31,10 @@ impl TunnelManager { self.active.lock().await.contains_key(tunnel_id) } + pub async fn active_count(&self) -> usize { + self.active.lock().await.len() + } + pub async fn open(&self, tunnel: &TunnelConfig, app: &AppHandle) -> AppResult<()> { { let active = self.active.lock().await; @@ -118,7 +123,25 @@ impl TunnelManager { }, ); - tracing::info!(tunnel_id = %tunnel.id, tunnel_type = %tunnel.tunnel_type, "Tunnel opened"); + log_event(StructuredLog { + level: StructuredLogLevel::Info, + domain: "session.lifecycle".to_string(), + event: "tunnel.opened".to_string(), + message: "Tunnel opened".to_string(), + ids: Some(serde_json::json!({ + "tunnel_id": tunnel.id.clone(), + "connection_id": tunnel.connection_id.clone(), + })), + data: Some(serde_json::json!({ + "tunnel_type": tunnel.tunnel_type.clone(), + "listen_port": tunnel.listen_port, + "target_host": tunnel.target_host.clone(), + "target_port": tunnel.target_port, + "bind_localhost": tunnel.bind_localhost, + })), + error: None, + client_timestamp: None, + }); Ok(()) } @@ -128,7 +151,16 @@ impl TunnelManager { if let Some(tx) = handle.shutdown_tx.take() { let _ = tx.send(()); } - tracing::info!(tunnel_id = %tunnel_id, "Tunnel closed"); + log_event(StructuredLog { + level: StructuredLogLevel::Info, + domain: "session.lifecycle".to_string(), + event: "tunnel.closed".to_string(), + message: "Tunnel closed".to_string(), + ids: Some(serde_json::json!({ "tunnel_id": tunnel_id })), + data: None, + error: None, + client_timestamp: None, + }); } } @@ -158,7 +190,19 @@ impl TunnelManager { ).await { Ok(ch) => ch, Err(e) => { - tracing::warn!("direct-tcpip failed: {}", e); + log_rate_limited(StructuredLog { + level: StructuredLogLevel::Warn, + domain: "session.lifecycle".to_string(), + event: "tunnel.direct_tcpip_failed".to_string(), + message: "Tunnel direct-tcpip failed".to_string(), + ids: None, + data: Some(serde_json::json!({ + "target_host": host, + "target_port": target_port, + })), + error: Some(serde_json::json!({ "message": e.to_string() })), + client_timestamp: None, + }); return; } } @@ -168,7 +212,16 @@ impl TunnelManager { }); } Err(e) => { - tracing::warn!("TCP accept failed: {}", e); + log_rate_limited(StructuredLog { + level: StructuredLogLevel::Warn, + domain: "session.lifecycle".to_string(), + event: "tunnel.accept_failed".to_string(), + message: "Tunnel TCP accept failed".to_string(), + ids: None, + data: None, + error: Some(serde_json::json!({ "message": e.to_string() })), + client_timestamp: None, + }); } } } @@ -187,14 +240,35 @@ impl TunnelManager { { let handle = ssh_handle.lock().await; if let Err(e) = handle.tcpip_forward(&listen_addr, listen_port.into()).await { - tracing::warn!("tcpip_forward request failed: {}", e); + log_rate_limited(StructuredLog { + level: StructuredLogLevel::Warn, + domain: "session.lifecycle".to_string(), + event: "tunnel.forward_request_failed".to_string(), + message: "Remote tunnel forward request failed".to_string(), + ids: None, + data: Some(serde_json::json!({ + "listen_addr": listen_addr.clone(), + "listen_port": listen_port, + })), + error: Some(serde_json::json!({ "message": e.to_string() })), + client_timestamp: None, + }); return; } } - tracing::info!( - listen = %format!("{}:{}", listen_addr, listen_port), - "Remote tunnel forwarding requested" - ); + log_event(StructuredLog { + level: StructuredLogLevel::Info, + domain: "session.lifecycle".to_string(), + event: "tunnel.forward_requested".to_string(), + message: "Remote tunnel forwarding requested".to_string(), + ids: None, + data: Some(serde_json::json!({ + "listen_addr": listen_addr.clone(), + "listen_port": listen_port, + })), + error: None, + client_timestamp: None, + }); let _ = shutdown_rx.await; @@ -219,7 +293,16 @@ impl TunnelManager { tokio::spawn(Self::handle_socks5_connection(stream, handle, peer_addr)); } Err(e) => { - tracing::warn!("SOCKS5 accept failed: {}", e); + log_rate_limited(StructuredLog { + level: StructuredLogLevel::Warn, + domain: "session.lifecycle".to_string(), + event: "tunnel.socks_accept_failed".to_string(), + message: "SOCKS5 accept failed".to_string(), + ids: None, + data: None, + error: Some(serde_json::json!({ "message": e.to_string() })), + client_timestamp: None, + }); } } } @@ -316,7 +399,19 @@ impl TunnelManager { } { Ok(ch) => ch, Err(e) => { - tracing::warn!("SOCKS5 direct-tcpip failed: {}", e); + log_rate_limited(StructuredLog { + level: StructuredLogLevel::Warn, + domain: "session.lifecycle".to_string(), + event: "tunnel.socks_direct_tcpip_failed".to_string(), + message: "SOCKS5 direct-tcpip failed".to_string(), + ids: None, + data: Some(serde_json::json!({ + "target_host": target_host, + "target_port": target_port, + })), + error: Some(serde_json::json!({ "message": e.to_string() })), + client_timestamp: None, + }); let _ = stream .write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) .await; @@ -342,11 +437,21 @@ impl TunnelManager { for tunnel in &tunnels { if tunnel.auto_open && tunnel.connection_id.as_deref() == Some(connection_id) { if let Err(e) = self.open(tunnel, app).await { - tracing::warn!( - tunnel_id = %tunnel.id, - "Failed to auto-open tunnel: {}", - e - ); + log_event(StructuredLog { + level: StructuredLogLevel::Warn, + domain: "session.lifecycle".to_string(), + event: "tunnel.auto_open_failed".to_string(), + message: "Failed to auto-open tunnel".to_string(), + ids: Some(serde_json::json!({ + "tunnel_id": tunnel.id.clone(), + "connection_id": connection_id, + })), + data: Some(serde_json::json!({ + "tunnel_type": tunnel.tunnel_type.clone(), + })), + error: Some(serde_json::json!({ "message": e.to_string() })), + client_timestamp: None, + }); } } } diff --git a/src-tauri/src/core/telnet.rs b/src-tauri/src/core/telnet.rs index 6ba31311..683bc045 100644 --- a/src-tauri/src/core/telnet.rs +++ b/src-tauri/src/core/telnet.rs @@ -5,6 +5,7 @@ use super::session::{ }; use crate::core::SessionOutputCoalescer; use crate::error::AppResult; +use crate::observability::{log_event, log_rate_limited, StructuredLog, StructuredLogLevel}; use std::sync::Arc; use tauri::{AppHandle, Emitter}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -113,7 +114,22 @@ pub async fn create_telnet_session( connection_id: Option, name: String, ) -> AppResult { - tracing::info!("Creating Telnet session to {}:{}", host, port); + log_event(StructuredLog { + level: StructuredLogLevel::Info, + domain: "session.lifecycle".to_string(), + event: "session.create_start".to_string(), + message: "Creating Telnet session".to_string(), + ids: connection_id + .as_ref() + .map(|value| serde_json::json!({ "connection_id": value })), + data: Some(serde_json::json!({ + "session_type": "Telnet", + "host": host, + "port": port, + })), + error: None, + client_timestamp: None, + }); let session_id = uuid::Uuid::new_v4().to_string(); let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::(); @@ -152,13 +168,29 @@ async fn telnet_session_task( mut cmd_rx: mpsc::UnboundedReceiver, host: String, port: u16, - _connection_id: Option, + connection_id: Option, ) { let addr = format!("{}:{}", host, port); let stream = match TcpStream::connect(&addr).await { Ok(s) => s, Err(e) => { - tracing::error!("Telnet connection failed: {}", e); + log_event(StructuredLog { + level: StructuredLogLevel::Error, + domain: "session.lifecycle".to_string(), + event: "session.connection_failed".to_string(), + message: "Telnet connection failed".to_string(), + ids: Some(serde_json::json!({ + "session_id": session_id.clone(), + "connection_id": connection_id.clone(), + })), + data: Some(serde_json::json!({ + "session_type": "Telnet", + "host": host, + "port": port, + })), + error: Some(serde_json::json!({ "message": e.to_string() })), + client_timestamp: None, + }); let _ = app.emit( &format!("session-error-{}", session_id), format!("Connection failed: {}", e), @@ -177,6 +209,7 @@ async fn telnet_session_task( let app_reader = app.clone(); let sid_reader = session_id.clone(); let output_reader = output.clone(); + let reader_connection_id = connection_id.clone(); // Shared channel for negotiation responses from the reader to the writer let (negotiate_tx, mut negotiate_rx) = mpsc::unbounded_channel::>(); @@ -200,7 +233,21 @@ async fn telnet_session_task( } } Err(e) => { - tracing::warn!("Telnet read error: {}", e); + log_rate_limited(StructuredLog { + level: StructuredLogLevel::Warn, + domain: "session.lifecycle".to_string(), + event: "telnet.read_error".to_string(), + message: "Telnet read error".to_string(), + ids: Some(serde_json::json!({ + "session_id": sid_reader.clone(), + "connection_id": reader_connection_id.clone(), + })), + data: Some(serde_json::json!({ + "session_type": "Telnet", + })), + error: Some(serde_json::json!({ "message": e.to_string() })), + client_timestamp: None, + }); break; } } @@ -213,7 +260,21 @@ async fn telnet_session_task( tokio::select! { Some(neg_data) = negotiate_rx.recv() => { if let Err(e) = writer.write_all(&neg_data).await { - tracing::warn!("Telnet negotiate write error: {}", e); + log_rate_limited(StructuredLog { + level: StructuredLogLevel::Warn, + domain: "session.lifecycle".to_string(), + event: "telnet.negotiate_write_error".to_string(), + message: "Telnet negotiate write error".to_string(), + ids: Some(serde_json::json!({ + "session_id": session_id.clone(), + "connection_id": connection_id.clone(), + })), + data: Some(serde_json::json!({ + "session_type": "Telnet", + })), + error: Some(serde_json::json!({ "message": e.to_string() })), + client_timestamp: None, + }); break; } } @@ -224,7 +285,21 @@ async fn telnet_session_task( } Some(SessionCommand::Write(data)) => { if let Err(e) = writer.write_all(&data).await { - tracing::warn!("Telnet write error: {}", e); + log_rate_limited(StructuredLog { + level: StructuredLogLevel::Warn, + domain: "session.lifecycle".to_string(), + event: "telnet.write_error".to_string(), + message: "Telnet write error".to_string(), + ids: Some(serde_json::json!({ + "session_id": session_id.clone(), + "connection_id": connection_id.clone(), + })), + data: Some(serde_json::json!({ + "session_type": "Telnet", + })), + error: Some(serde_json::json!({ "message": e.to_string() })), + client_timestamp: None, + }); break; } } diff --git a/src-tauri/src/core/watcher.rs b/src-tauri/src/core/watcher.rs index d8d44d1b..80a0e64a 100644 --- a/src-tauri/src/core/watcher.rs +++ b/src-tauri/src/core/watcher.rs @@ -7,6 +7,7 @@ use std::time::Duration; use tauri::{AppHandle, Emitter}; use crate::error::{AppError, AppResult}; +use crate::observability::{log_event, log_rate_limited, StructuredLog, StructuredLogLevel}; #[derive(Clone, Serialize)] pub struct FileModifiedPayload { @@ -70,7 +71,19 @@ pub async fn start_file_watch( let local_path_clone = local_path.clone(); let remote_path_clone = remote_path.clone(); - tracing::info!("Starting file watch for local path: {}", local_path); + log_event(StructuredLog { + level: StructuredLogLevel::Info, + domain: "watcher.sync".to_string(), + event: "watch.start".to_string(), + message: "Starting file watch".to_string(), + ids: Some(serde_json::json!({ "session_id": session_id })), + data: Some(serde_json::json!({ + "local_path": local_path, + "remote_path": remote_path, + })), + error: None, + client_timestamp: None, + }); // Spawn a blocking thread to listen for notify events std::thread::spawn(move || { @@ -78,15 +91,15 @@ pub async fn start_file_watch( for res in rx { match res { Ok(event) => { - tracing::info!("Notify event received: {:?}", event.kind); + tracing::debug!(kind = ?event.kind, "Notify event received"); // Most text editors do atomic saves (save to temp file, then rename/move) // We need to catch Any/Data modify events inside the file OR rename events on the filepath if let EventKind::Modify(_) = event.kind { - tracing::info!("Detected Modify event on: {:?}", event.paths); + tracing::debug!(paths = ?event.paths, "Detected modify event"); // Debounce: prevent emitting multiple times for a single save operation (common in editors) if last_emit.elapsed() > Duration::from_millis(500) { - tracing::info!("Debounce passed, emitting 'file-modified' payload..."); + tracing::debug!("Debounce passed, emitting file-modified payload"); last_emit = std::time::Instant::now(); let payload = FileModifiedPayload { session_id: session_id_clone.clone(), @@ -94,15 +107,39 @@ pub async fn start_file_watch( remote_path: remote_path_clone.clone(), }; if let Err(e) = app_clone.emit("file-modified", payload) { - tracing::error!("Failed to emit file-modified event: {:?}", e); + log_rate_limited(StructuredLog { + level: StructuredLogLevel::Error, + domain: "watcher.sync".to_string(), + event: "watch.emit_failed".to_string(), + message: "Failed to emit file-modified event".to_string(), + ids: Some(serde_json::json!({ "session_id": session_id_clone.clone() })), + data: Some(serde_json::json!({ + "local_path": local_path_clone.clone(), + "remote_path": remote_path_clone.clone(), + })), + error: Some(serde_json::json!({ "message": e.to_string() })), + client_timestamp: None, + }); } } else { - tracing::info!("Event debounced (too soon since last emit)."); + tracing::debug!("Watcher event debounced"); } } } Err(e) => { - tracing::error!("watch error: {:?}", e); + log_rate_limited(StructuredLog { + level: StructuredLogLevel::Error, + domain: "watcher.sync".to_string(), + event: "watch.backend_error".to_string(), + message: "File watcher backend error".to_string(), + ids: Some(serde_json::json!({ "session_id": session_id_clone.clone() })), + data: Some(serde_json::json!({ + "local_path": local_path_clone.clone(), + "remote_path": remote_path_clone.clone(), + })), + error: Some(serde_json::json!({ "message": e.to_string() })), + client_timestamp: None, + }); break; } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b3521ca8..3ca99168 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod cmd; mod config; mod core; mod error; +mod observability; mod utils; use std::sync::Arc; @@ -32,7 +33,8 @@ pub fn run() { .on_window_event(app::on_window_event) .invoke_handler(tauri::generate_handler![ cmd::clipboard::read_clipboard_text, - cmd::log::write_log, + cmd::log::append_frontend_logs, + cmd::log::export_diagnostics, cmd::settings::get_system_fonts, cmd::session::create_ssh_session, cmd::session::create_local_session, diff --git a/src-tauri/src/observability.rs b/src-tauri/src/observability.rs new file mode 100644 index 00000000..3c910a28 --- /dev/null +++ b/src-tauri/src/observability.rs @@ -0,0 +1,849 @@ +use std::collections::HashMap; +use std::fmt; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, Instant, SystemTime}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use tauri::{AppHandle, Manager}; +use tracing::{Event, Level, Subscriber}; +use tracing_subscriber::fmt::format::Writer; +use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields}; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::registry::LookupSpan; +use tracing_subscriber::{fmt as subscriber_fmt, util::SubscriberInitExt, EnvFilter}; +use zip::write::SimpleFileOptions; + +use crate::config::{self, DiagnosticsLogLevel, DiagnosticsSettings}; +use crate::core::ssh::sftp; +use crate::core::{SessionInfo, SessionManager, SessionType}; +use crate::error::{AppError, AppResult}; + +pub const LOG_FILE_PREFIX: &str = "dragonfly-diagnostics"; +pub const LOG_FILE_SUFFIX: &str = "jsonl"; + +const DEFAULT_RETENTION_DAYS: u32 = 7; +const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(30); + +lazy_static::lazy_static! { + static ref RATE_LIMITS: Mutex> = Mutex::new(HashMap::new()); +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum StructuredLogLevel { + Debug, + Info, + Warn, + Error, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct FrontendLogEntry { + #[serde(default)] + pub timestamp: Option, + pub level: StructuredLogLevel, + pub domain: String, + pub event: String, + pub message: String, + #[serde(default)] + pub ids: Option, + #[serde(default)] + pub data: Option, + #[serde(default)] + pub error: Option, +} + +#[derive(Clone, Debug)] +pub struct StructuredLog { + pub level: StructuredLogLevel, + pub domain: String, + pub event: String, + pub message: String, + pub ids: Option, + pub data: Option, + pub error: Option, + pub client_timestamp: Option, +} + +#[derive(Clone, Debug)] +struct RateLimitState { + last_emitted_at: Instant, + suppressed_count: u32, +} + +#[derive(Clone, Default)] +struct StructuredJsonFormatter; + +#[derive(Default)] +struct JsonFieldVisitor { + fields: Map, +} + +pub fn init_tracing(log_dir: PathBuf, settings: &DiagnosticsSettings) { + let retention_days = normalize_retention_days(settings.retention_days); + let _ = std::fs::create_dir_all(&log_dir); + let _ = cleanup_old_logs(&log_dir, retention_days); + + let file_appender = tracing_appender::rolling::Builder::new() + .rotation(tracing_appender::rolling::Rotation::DAILY) + .filename_prefix(LOG_FILE_PREFIX) + .filename_suffix(LOG_FILE_SUFFIX) + .max_log_files(retention_days as usize) + .build(&log_dir) + .expect("failed to initialize rolling file appender"); + + let filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_filter(settings.level))); + + let local_time = subscriber_fmt::time::OffsetTime::local_rfc_3339().unwrap_or_else(|_| { + subscriber_fmt::time::OffsetTime::new( + time::UtcOffset::UTC, + time::format_description::well_known::Rfc3339, + ) + }); + + let file_layer = subscriber_fmt::layer() + .event_format(StructuredJsonFormatter) + .with_writer(file_appender) + .with_ansi(false); + + let subscriber = tracing_subscriber::registry().with(filter).with(file_layer); + + if cfg!(debug_assertions) { + subscriber + .with( + subscriber_fmt::layer() + .with_writer(std::io::stderr) + .compact() + .with_timer(local_time), + ) + .init(); + } else { + subscriber.init(); + } + + log_event(StructuredLog { + level: StructuredLogLevel::Info, + domain: "app.lifecycle".to_string(), + event: "app.start".to_string(), + message: "Dragonfly starting".to_string(), + ids: None, + data: Some(json!({ + "log_level": settings.level.as_str(), + "retention_days": retention_days, + })), + error: None, + client_timestamp: None, + }); +} + +pub fn log_event(log: StructuredLog) { + let ids = serialize_json_value(log.ids); + let data = serialize_json_value(log.data); + let error = serialize_json_value(log.error); + let client_timestamp = log.client_timestamp.unwrap_or_default(); + match log.level { + StructuredLogLevel::Debug => tracing::event!( + Level::DEBUG, + domain = log.domain.as_str(), + event = log.event.as_str(), + message = log.message.as_str(), + ids = ids.as_deref().unwrap_or(""), + data = data.as_deref().unwrap_or(""), + error = error.as_deref().unwrap_or(""), + client_timestamp = client_timestamp.as_str(), + ), + StructuredLogLevel::Info => tracing::event!( + Level::INFO, + domain = log.domain.as_str(), + event = log.event.as_str(), + message = log.message.as_str(), + ids = ids.as_deref().unwrap_or(""), + data = data.as_deref().unwrap_or(""), + error = error.as_deref().unwrap_or(""), + client_timestamp = client_timestamp.as_str(), + ), + StructuredLogLevel::Warn => tracing::event!( + Level::WARN, + domain = log.domain.as_str(), + event = log.event.as_str(), + message = log.message.as_str(), + ids = ids.as_deref().unwrap_or(""), + data = data.as_deref().unwrap_or(""), + error = error.as_deref().unwrap_or(""), + client_timestamp = client_timestamp.as_str(), + ), + StructuredLogLevel::Error => tracing::event!( + Level::ERROR, + domain = log.domain.as_str(), + event = log.event.as_str(), + message = log.message.as_str(), + ids = ids.as_deref().unwrap_or(""), + data = data.as_deref().unwrap_or(""), + error = error.as_deref().unwrap_or(""), + client_timestamp = client_timestamp.as_str(), + ), + } +} + +pub fn log_rate_limited(log: StructuredLog) { + let key = rate_limit_key(&log); + let now = Instant::now(); + let mut should_log = true; + let mut summary_count = None; + + { + let mut states = RATE_LIMITS.lock().unwrap(); + match states.get_mut(&key) { + Some(state) if now.duration_since(state.last_emitted_at) < RATE_LIMIT_WINDOW => { + state.suppressed_count += 1; + should_log = false; + } + Some(state) => { + if state.suppressed_count > 0 { + summary_count = Some(state.suppressed_count); + } + state.last_emitted_at = now; + state.suppressed_count = 0; + } + None => { + states.insert( + key, + RateLimitState { + last_emitted_at: now, + suppressed_count: 0, + }, + ); + } + } + } + + if let Some(suppressed_count) = summary_count { + let mut data = log.data.clone().unwrap_or_else(|| Value::Object(Map::new())); + if let Value::Object(ref mut map) = data { + map.insert( + "suppressed_count".to_string(), + Value::Number(serde_json::Number::from(suppressed_count)), + ); + map.insert( + "suppressed_window_seconds".to_string(), + Value::Number(serde_json::Number::from(RATE_LIMIT_WINDOW.as_secs())), + ); + } + log_event(StructuredLog { + level: log.level, + domain: log.domain.clone(), + event: format!("{}.suppressed", log.event), + message: "Suppressed repeated log entries".to_string(), + ids: log.ids.clone(), + data: Some(data), + error: None, + client_timestamp: None, + }); + } + + if should_log { + log_event(log); + } +} + +pub fn export_diagnostics( + app: &AppHandle, + session_manager: &SessionManager, + tunnel_manager: &crate::core::ssh::TunnelManager, + output_path: &str, +) -> AppResult<()> { + let settings = config::load_app_settings(app).unwrap_or_default(); + let log_dir = app.path().app_log_dir().map_err(|e| AppError::Config(e.to_string()))?; + let log_files = collect_log_files( + &log_dir, + normalize_retention_days(settings.diagnostics.retention_days), + )?; + + if let Some(parent) = Path::new(output_path).parent() { + let _ = std::fs::create_dir_all(parent); + } + + let file = std::fs::File::create(output_path)?; + let mut zip = zip::ZipWriter::new(file); + let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + + for log_file in log_files { + let contents = std::fs::read(&log_file)?; + let file_name = log_file + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| AppError::Config("invalid log file name".to_string()))?; + zip.start_file(file_name, options) + .map_err(|e| AppError::Config(format!("zip write: {e}")))?; + use std::io::Write; + zip.write_all(&contents)?; + } + + let manifest = build_manifest(app, &settings); + zip.start_file("manifest.json", options) + .map_err(|e| AppError::Config(format!("zip write: {e}")))?; + use std::io::Write; + zip.write_all( + serde_json::to_string_pretty(&manifest) + .map_err(|e| AppError::Config(format!("serialize manifest: {e}")))? + .as_bytes(), + )?; + + let runtime_snapshot = build_runtime_snapshot(app, &settings, session_manager, tunnel_manager); + zip.start_file("runtime_snapshot.json", options) + .map_err(|e| AppError::Config(format!("zip write: {e}")))?; + zip.write_all( + serde_json::to_string_pretty(&runtime_snapshot) + .map_err(|e| AppError::Config(format!("serialize runtime snapshot: {e}")))? + .as_bytes(), + )?; + + zip.finish() + .map_err(|e| AppError::Config(format!("zip finalize: {e}")))?; + + Ok(()) +} + +pub fn frontend_log_to_structured(entry: FrontendLogEntry) -> StructuredLog { + StructuredLog { + level: entry.level, + domain: entry.domain, + event: entry.event, + message: entry.message, + ids: entry.ids, + data: entry.data, + error: entry.error, + client_timestamp: entry.timestamp, + } +} + +impl FormatEvent for StructuredJsonFormatter +where + S: Subscriber + for<'lookup> LookupSpan<'lookup>, + N: for<'writer> FormatFields<'writer> + 'static, +{ + fn format_event( + &self, + _ctx: &FmtContext<'_, S, N>, + mut writer: Writer<'_>, + event: &Event<'_>, + ) -> fmt::Result { + let metadata = event.metadata(); + let mut visitor = JsonFieldVisitor::default(); + event.record(&mut visitor); + + let entry = build_log_entry_json( + &metadata.level().to_string().to_ascii_lowercase(), + metadata.target(), + visitor.fields, + format_timestamp(), + ); + let serialized = serde_json::to_string(&entry).map_err(|_| fmt::Error)?; + writer.write_str(&serialized)?; + writer.write_str("\n") + } +} + +impl tracing::field::Visit for JsonFieldVisitor { + fn record_i64(&mut self, field: &tracing::field::Field, value: i64) { + self.fields.insert( + field.name().to_string(), + Value::Number(serde_json::Number::from(value)), + ); + } + + fn record_u64(&mut self, field: &tracing::field::Field, value: u64) { + self.fields.insert( + field.name().to_string(), + Value::Number(serde_json::Number::from(value)), + ); + } + + fn record_bool(&mut self, field: &tracing::field::Field, value: bool) { + self.fields + .insert(field.name().to_string(), Value::Bool(value)); + } + + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + self.fields + .insert(field.name().to_string(), Value::String(value.to_string())); + } + + fn record_error( + &mut self, + field: &tracing::field::Field, + value: &(dyn std::error::Error + 'static), + ) { + self.fields.insert( + field.name().to_string(), + Value::String(value.to_string()), + ); + } + + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) { + self.fields.insert( + field.name().to_string(), + Value::String(format!("{value:?}")), + ); + } + + fn record_f64(&mut self, field: &tracing::field::Field, value: f64) { + if let Some(number) = serde_json::Number::from_f64(value) { + self.fields + .insert(field.name().to_string(), Value::Number(number)); + } + } +} + +fn build_log_entry_json( + level: &str, + target: &str, + mut fields: Map, + timestamp: String, +) -> Value { + let domain = take_string_field(&mut fields, "domain").unwrap_or_else(|| target.to_string()); + let event = take_string_field(&mut fields, "event").unwrap_or_else(|| "unclassified".to_string()); + let message = take_string_field(&mut fields, "message").unwrap_or_else(|| target.to_string()); + let client_timestamp = take_string_field(&mut fields, "client_timestamp"); + + let ids = parse_reserved_json_field(fields.remove("ids")); + let error = parse_reserved_json_field(fields.remove("error")); + let mut data = match parse_reserved_json_field(fields.remove("data")) { + Some(Value::Object(map)) => map, + Some(other) => { + let mut map = Map::new(); + map.insert("value".to_string(), other); + map + } + None => Map::new(), + }; + + if let Some(client_timestamp) = client_timestamp { + data.insert( + "client_timestamp".to_string(), + Value::String(client_timestamp), + ); + } + + for (key, value) in fields { + if matches!(value, Value::String(ref text) if text.is_empty()) { + continue; + } + data.insert(key, value); + } + + let mut entry = Map::new(); + entry.insert("timestamp".to_string(), Value::String(timestamp)); + entry.insert("level".to_string(), Value::String(level.to_string())); + entry.insert("domain".to_string(), Value::String(domain)); + entry.insert("event".to_string(), Value::String(event)); + entry.insert( + "message".to_string(), + sanitize_value(Some("message"), Value::String(message)), + ); + if let Some(ids) = ids { + entry.insert("ids".to_string(), sanitize_value(Some("ids"), ids)); + } + if !data.is_empty() { + entry.insert( + "data".to_string(), + sanitize_value(Some("data"), Value::Object(data)), + ); + } + if let Some(error) = error { + entry.insert("error".to_string(), sanitize_value(Some("error"), error)); + } + + Value::Object(entry) +} + +fn build_manifest(app: &AppHandle, settings: &config::AppSettings) -> Value { + json!({ + "app_version": app.package_info().version.to_string(), + "platform": std::env::consts::OS, + "arch": std::env::consts::ARCH, + "language": settings.ui.language.clone().unwrap_or_else(|| "en".to_string()), + "log_level": settings.diagnostics.level.as_str(), + "retention_days": normalize_retention_days(settings.diagnostics.retention_days), + "exported_at": format_timestamp(), + }) +} + +fn build_runtime_snapshot( + app: &AppHandle, + settings: &config::AppSettings, + session_manager: &SessionManager, + tunnel_manager: &crate::core::ssh::TunnelManager, +) -> Value { + let sessions = tauri::async_runtime::block_on(session_manager.list_sessions()); + let tunnel_count = tauri::async_runtime::block_on(tunnel_manager.active_count()); + let saved_tunnels = config::load_tunnels(app).map(|items| items.len()).unwrap_or(0); + let session_summary = summarize_sessions(&sessions); + + json!({ + "diagnostics": { + "level": settings.diagnostics.level.as_str(), + "retention_days": normalize_retention_days(settings.diagnostics.retention_days), + }, + "ui": { + "language": settings.ui.language.clone().unwrap_or_else(|| "en".to_string()), + "active_left_panel": settings.ui.active_left_panel, + "active_right_panel": settings.ui.active_right_panel, + "show_quick_cmd_bar": settings.ui.show_quick_cmd_bar, + "show_serial_send_panel": settings.ui.show_serial_send_panel, + "show_remote_stats": settings.ui.show_remote_stats, + "open_tab_count": settings.ui.open_tabs.len(), + }, + "session_summary": session_summary, + "transfer_summary": { + "active_count": sftp::active_transfer_count(), + }, + "tunnel_summary": { + "active_count": tunnel_count, + "saved_count": saved_tunnels, + }, + }) +} + +fn summarize_sessions(sessions: &[SessionInfo]) -> Value { + let mut summary = json!({ + "active_total": sessions.len(), + "ssh": 0, + "local": 0, + "telnet": 0, + "serial": 0, + "cwd_tracking_enabled": 0, + }); + + if let Value::Object(ref mut map) = summary { + for session in sessions { + let key = match session.session_type { + SessionType::SSH => "ssh", + SessionType::Local => "local", + SessionType::Telnet => "telnet", + SessionType::Serial => "serial", + }; + increment_counter(map, key); + if session.injection_active { + increment_counter(map, "cwd_tracking_enabled"); + } + } + } + + summary +} + +fn increment_counter(map: &mut Map, key: &str) { + let next = map + .get(key) + .and_then(Value::as_u64) + .unwrap_or(0) + .saturating_add(1); + map.insert(key.to_string(), Value::Number(serde_json::Number::from(next))); +} + +fn collect_log_files(log_dir: &Path, retention_days: u32) -> AppResult> { + let min_modified = threshold_system_time(retention_days); + let mut files = Vec::new(); + for entry in std::fs::read_dir(log_dir)? { + let entry = entry?; + let path = entry.path(); + if !is_log_file(&path) { + continue; + } + let modified = entry + .metadata() + .and_then(|metadata| metadata.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH); + if modified < min_modified { + continue; + } + files.push(path); + } + files.sort(); + Ok(files) +} + +fn cleanup_old_logs(log_dir: &Path, retention_days: u32) -> AppResult<()> { + let min_modified = threshold_system_time(retention_days); + for entry in std::fs::read_dir(log_dir)? { + let entry = entry?; + let path = entry.path(); + if !is_log_file(&path) { + continue; + } + let modified = entry + .metadata() + .and_then(|metadata| metadata.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH); + if modified < min_modified { + let _ = std::fs::remove_file(path); + } + } + Ok(()) +} + +fn threshold_system_time(retention_days: u32) -> SystemTime { + SystemTime::now() + .checked_sub(Duration::from_secs(u64::from(retention_days) * 24 * 60 * 60)) + .unwrap_or(SystemTime::UNIX_EPOCH) +} + +fn is_log_file(path: &Path) -> bool { + path.file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.starts_with(LOG_FILE_PREFIX) && value.ends_with(LOG_FILE_SUFFIX)) +} + +fn serialize_json_value(value: Option) -> Option { + value.and_then(|item| serde_json::to_string(&item).ok()) +} + +fn parse_reserved_json_field(value: Option) -> Option { + match value { + Some(Value::String(text)) if text.is_empty() => None, + Some(Value::String(text)) => serde_json::from_str(&text) + .ok() + .or_else(|| Some(Value::String(text))), + Some(other) => Some(other), + None => None, + } +} + +fn take_string_field(fields: &mut Map, key: &str) -> Option { + match fields.remove(key) { + Some(Value::String(value)) if !value.is_empty() => Some(value), + Some(other) => Some(other.to_string()), + None => None, + } +} + +fn rate_limit_key(log: &StructuredLog) -> String { + let ids = log + .ids + .as_ref() + .and_then(|value| serde_json::to_string(value).ok()) + .unwrap_or_default(); + let error = log + .error + .as_ref() + .and_then(normalized_error_key) + .unwrap_or_default(); + format!("{}|{}|{}", log.event, ids, error) +} + +fn normalized_error_key(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(text) => Some(text.clone()), + other => serde_json::to_string(other).ok(), + } +} + +fn sanitize_value(key: Option<&str>, value: Value) -> Value { + match value { + Value::Object(map) => Value::Object( + map.into_iter() + .map(|(child_key, child_value)| { + let sanitized = sanitize_value(Some(&child_key), child_value); + (child_key, sanitized) + }) + .collect(), + ), + Value::Array(items) => Value::Array( + items + .into_iter() + .map(|item| sanitize_value(key, item)) + .collect(), + ), + Value::String(text) => sanitize_string(key, text), + other => other, + } +} + +fn sanitize_string(key: Option<&str>, value: String) -> Value { + let Some(key) = key else { + return Value::String(value); + }; + + if is_redacted_key(key) { + return Value::String("[REDACTED]".to_string()); + } + + if is_host_key(key) { + return json!({ + "type": "host", + "hash": stable_hash("host", &value), + }); + } + + if is_username_key(key) { + return json!({ + "type": "username", + "hash": stable_hash("username", &value), + }); + } + + if is_path_key(key) { + let extension = Path::new(&value) + .extension() + .and_then(|item| item.to_str()) + .map(ToString::to_string); + let mut map = Map::new(); + map.insert("type".to_string(), Value::String("path".to_string())); + map.insert( + "hash".to_string(), + Value::String(stable_hash("path", &value)), + ); + if let Some(extension) = extension { + map.insert("extension".to_string(), Value::String(extension)); + } + return Value::Object(map); + } + + Value::String(value) +} + +fn is_redacted_key(key: &str) -> bool { + let key = normalize_key(key); + matches!( + key.as_str(), + "password" + | "secret" + | "token" + | "otp" + | "command" + | "content" + | "clipboard" + | "passphrase" + | "master_password" + ) || key.ends_with("_password") + || key.ends_with("_secret") + || key.ends_with("_token") + || key.ends_with("_otp") + || key.ends_with("_command") + || key.ends_with("_content") + || key.ends_with("_clipboard") + || key.ends_with("_passphrase") + || key.contains("private_key") + || key.contains("public_key") + || key.contains("key_data") + || key.contains("secret_key") + || (key.ends_with("_key") && !key.ends_with("_id")) +} + +fn is_host_key(key: &str) -> bool { + let key = normalize_key(key); + key == "host" || key.ends_with("_host") +} + +fn is_username_key(key: &str) -> bool { + let key = normalize_key(key); + key == "username" || key == "user" || key.ends_with("_username") +} + +fn is_path_key(key: &str) -> bool { + let key = normalize_key(key); + key == "path" || key == "cwd" || key.ends_with("_path") || key.ends_with("_dir") +} + +fn normalize_key(key: &str) -> String { + key.chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '_' + } + }) + .collect() +} + +fn stable_hash(kind: &str, value: &str) -> String { + use sha2::Digest as _; + + let mut hasher = sha2::Sha256::new(); + hasher.update(kind.as_bytes()); + hasher.update(b":"); + hasher.update(value.as_bytes()); + let digest = hasher.finalize(); + hex::encode(&digest[..8]) +} + +fn default_filter(level: DiagnosticsLogLevel) -> String { + let app_level = match level { + DiagnosticsLogLevel::Warn => "warn", + DiagnosticsLogLevel::Info => "info", + DiagnosticsLogLevel::Debug => "debug", + }; + format!("dragonfly={app_level},dragonfly_lib={app_level},warn") +} + +fn normalize_retention_days(retention_days: u32) -> u32 { + match retention_days { + 0 => DEFAULT_RETENTION_DAYS, + value => value.min(30), + } +} + +fn format_timestamp() -> String { + let now = time::OffsetDateTime::now_local() + .unwrap_or_else(|_| time::OffsetDateTime::now_utc()); + now.format(&time::format_description::well_known::Rfc3339) + .unwrap_or_else(|_| now.unix_timestamp().to_string()) +} + +#[cfg(test)] +mod tests { + use super::{build_log_entry_json, sanitize_value, Value}; + use serde_json::{json, Map}; + + #[test] + fn sanitize_value_redacts_secret_fields() { + let value = json!({ + "password": "hunter2", + "remote_path": "/var/log/app.log", + "host": "example.com", + "username": "root", + }); + + let sanitized = sanitize_value(Some("data"), value); + assert_eq!(sanitized["password"], "[REDACTED]"); + assert_eq!(sanitized["remote_path"]["type"], "path"); + assert_eq!(sanitized["host"]["type"], "host"); + assert_eq!(sanitized["username"]["type"], "username"); + } + + #[test] + fn build_log_entry_merges_reserved_and_extra_fields() { + let mut fields = Map::new(); + fields.insert("domain".to_string(), Value::String("ui.error".to_string())); + fields.insert("event".to_string(), Value::String("dialog.open_failed".to_string())); + fields.insert("message".to_string(), Value::String("Open failed".to_string())); + fields.insert( + "ids".to_string(), + Value::String("{\"session_id\":\"abc\"}".to_string()), + ); + fields.insert( + "data".to_string(), + Value::String("{\"action\":\"open\"}".to_string()), + ); + fields.insert("attempt".to_string(), Value::Number(1u64.into())); + + let entry = build_log_entry_json( + "error", + "dragonfly_lib::ui", + fields, + "2026-01-01T00:00:00Z".to_string(), + ); + + assert_eq!(entry["domain"], "ui.error"); + assert_eq!(entry["event"], "dialog.open_failed"); + assert_eq!(entry["ids"]["session_id"], "abc"); + assert_eq!(entry["data"]["action"], "open"); + assert_eq!(entry["data"]["attempt"], 1); + } +} diff --git a/src/App.tsx b/src/App.tsx index 157d8f5e..72ba3f94 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -226,9 +226,7 @@ function App() { setHelpDotVisible(true); } }) - .catch((err) => { - console.warn("Update check failed:", err); - }); + .catch(() => {}); }, 3000); return () => clearTimeout(timer); }, []); @@ -615,7 +613,13 @@ function App() { await invoke("close_session", { sessionId: pane.sessionId }); return true; } catch (error) { - logger.error("Failed to close session", error); + logger.error({ + domain: "session.lifecycle", + event: "session.close_failed", + message: "Failed to close session", + ids: { session_id: pane.sessionId }, + error, + }); return false; } }, @@ -638,7 +642,12 @@ function App() { await persistTabsNow(); return true; } catch (error) { - logger.error("Failed to persist workspace tabs", error); + logger.error({ + domain: "settings.persistence", + event: "workspace_tabs.persist_failed", + message: "Failed to persist workspace tabs", + error, + }); toast.error(message); return false; } @@ -710,7 +719,15 @@ function App() { .then((sessionId) => { addTab(sessionId, t("menu.newLocalTerminal"), "Local"); }) - .catch((e) => logger.error("Failed to create local session", e)); + .catch((e) => + logger.error({ + domain: "session.lifecycle", + event: "session.create_failed", + message: "Failed to create local session", + data: { session_type: "Local" }, + error: e, + }), + ); }, [addTab, t]); const handleCloseActiveTab = useCallback(() => { @@ -841,13 +858,24 @@ function App() { updateTabSession(tabId, sessionId); } catch (error) { const errorMessage = getErrorMessage(error); - logger.error("Failed to duplicate session", error); + logger.error({ + domain: "session.lifecycle", + event: "session.duplicate_failed", + message: "Failed to duplicate session", + ids: pane.connectionId ? { connection_id: pane.connectionId } : undefined, + error, + }); markTabConnectionFailed(tabId, errorMessage); maybePromptConnectionEdit(pane.connectionId, errorMessage, { sourceTabId: tabId }); toast.error(t("tabCtx.duplicateFailed")); } } catch (error) { - logger.error("Failed to create duplicated tab", error); + logger.error({ + domain: "ui.error", + event: "tab.duplicate_failed", + message: "Failed to create duplicated tab", + error, + }); toast.error(t("tabCtx.duplicateFailed")); } }, @@ -879,7 +907,13 @@ function App() { toast.success(t("tabCtx.reconnectSuccess")); } catch (error) { const errorMessage = getErrorMessage(error); - logger.error("Failed to reconnect session", error); + logger.error({ + domain: "session.lifecycle", + event: "session.reconnect_failed", + message: "Failed to reconnect session", + ids: pane.connectionId ? { connection_id: pane.connectionId } : undefined, + error, + }); maybePromptConnectionEdit(pane.connectionId, errorMessage, { sourceTabId: tab.id, sourcePaneId: pane.id, @@ -915,7 +949,13 @@ function App() { toast.success(t("tabCtx.reconnectSuccess")); } catch (error) { const errorMessage = getErrorMessage(error); - logger.error("Failed to reconnect session from active sessions panel", error); + logger.error({ + domain: "session.lifecycle", + event: "session.reconnect_failed", + message: "Failed to reconnect session from active sessions panel", + ids: pane.connectionId ? { connection_id: pane.connectionId } : undefined, + error, + }); maybePromptConnectionEdit(pane.connectionId, errorMessage, { sourceTabId: tab.id, sourcePaneId: pane.id, @@ -972,7 +1012,13 @@ function App() { window.dispatchEvent(new CustomEvent("dragonfly:refresh-terminals")); } catch (error) { const errorMessage = getErrorMessage(error); - logger.error("Failed to create split session", error); + logger.error({ + domain: "session.lifecycle", + event: "session.split_failed", + message: "Failed to create split session", + ids: pane.connectionId ? { connection_id: pane.connectionId } : undefined, + error, + }); if (newTabId) { markTabConnectionFailed(newTabId, errorMessage); } @@ -1014,7 +1060,13 @@ function App() { updatePaneSession(tabId, paneId, newSessionId); } catch (error) { const errorMessage = getErrorMessage(error); - logger.error("Failed to reconnect pane", error); + logger.error({ + domain: "session.lifecycle", + event: "session.reconnect_failed", + message: "Failed to reconnect pane", + ids: pane.connectionId ? { connection_id: pane.connectionId } : undefined, + error, + }); markPaneConnectionFailed(tabId, paneId, errorMessage); maybePromptConnectionEdit(pane.connectionId, errorMessage, { sourceTabId: tabId, @@ -1058,7 +1110,13 @@ function App() { try { await invoke("close_session", { sessionId }); } catch (error) { - logger.error("Failed to disconnect session outside workspace", error); + logger.error({ + domain: "session.lifecycle", + event: "session.close_failed", + message: "Failed to disconnect session outside workspace", + ids: { session_id: sessionId }, + error, + }); toast.error(t("tabCtx.closeFailed")); } return; @@ -1205,7 +1263,13 @@ function App() { }); toast.success(t("recording.saved", { path: savedPath })); } catch (e) { - console.error("Failed to stop recording", e); + logger.error({ + domain: "session.lifecycle", + event: "recording.stop_failed", + message: "Failed to stop recording", + ids: { session_id: sessionId }, + error: e, + }); } } else { try { @@ -1220,7 +1284,13 @@ function App() { return next; }); } catch (e) { - console.error("Failed to start recording", e); + logger.error({ + domain: "session.lifecycle", + event: "recording.start_failed", + message: "Failed to start recording", + ids: { session_id: sessionId }, + error: e, + }); } } }, [activePane, activeTab, appSettings.transfer.recording_path, recordingSessions, t]); diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index f22a7714..962fd43b 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -23,7 +23,15 @@ export default class ErrorBoundary extends Component { } componentDidCatch(error: Error, info: ErrorInfo) { - logger.error("Uncaught React error", error, info.componentStack); + logger.error({ + domain: "ui.error", + event: "react.uncaught_error", + message: "Uncaught React error", + data: { + component_stack_length: info.componentStack?.length ?? 0, + }, + error, + }); } render() { diff --git a/src/components/dialog/app/LockScreen.tsx b/src/components/dialog/app/LockScreen.tsx index 82a16ce3..743b4fd2 100644 --- a/src/components/dialog/app/LockScreen.tsx +++ b/src/components/dialog/app/LockScreen.tsx @@ -1,7 +1,7 @@ -import { invoke } from "@tauri-apps/api/core"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { MdLock, MdLockOpen } from "react-icons/md"; +import { invoke } from "@/lib/invoke"; import DragonflyLogo from "../../DragonflyLogo"; interface LockScreenProps { diff --git a/src/components/dialog/connections/ImportDialog.tsx b/src/components/dialog/connections/ImportDialog.tsx index e3d0c57c..ebe9498f 100644 --- a/src/components/dialog/connections/ImportDialog.tsx +++ b/src/components/dialog/connections/ImportDialog.tsx @@ -42,7 +42,7 @@ const IMPORT_SOURCES: ImportSource[] = [ icon: "/Xshell.svg", extensions: ["xts"], hint: ".xts", - type: "sessions" + type: "sessions", }, { id: "mobaxterm", @@ -89,7 +89,12 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) { toast.info(t("savedConnections.importSuccess", { count: 0 })); } } catch (e) { - logger.error("Import sessions failed", e); + logger.error({ + domain: "settings.persistence", + event: "sessions.import_failed", + message: "Import sessions failed", + error: e, + }); toast.error(t("savedConnections.importFailed", { error: e })); } }; diff --git a/src/components/dialog/connections/OtpDialog.tsx b/src/components/dialog/connections/OtpDialog.tsx index 547bd9f5..496f2701 100644 --- a/src/components/dialog/connections/OtpDialog.tsx +++ b/src/components/dialog/connections/OtpDialog.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { OtpCodePanel } from "@/components/panel/security-auth/OtpCodePanel"; @@ -13,6 +12,8 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { invoke } from "@/lib/invoke"; +import { logger } from "@/lib/logger"; export interface OtpPrompt { prompt: string; @@ -59,8 +60,21 @@ export function OtpDialog({ request, onDone }: OtpDialogProps) { requestId: request.requestId, responses, }); - } catch { - /* backend handles the error */ + logger.info({ + domain: "security.flow", + event: "otp.response_submitted", + message: "Submitted OTP response", + ids: { request_id: request.requestId }, + data: { prompt_count: responses.length }, + }); + } catch (error) { + logger.error({ + domain: "security.flow", + event: "otp.response_submit_failed", + message: "Failed to submit OTP response", + ids: { request_id: request.requestId }, + error, + }); } onDone(); }; @@ -69,8 +83,20 @@ export function OtpDialog({ request, onDone }: OtpDialogProps) { if (!request) return; try { await invoke("cancel_otp_request", { requestId: request.requestId }); - } catch { - /* ignore */ + logger.info({ + domain: "security.flow", + event: "otp.request_cancelled", + message: "Cancelled OTP request", + ids: { request_id: request.requestId }, + }); + } catch (error) { + logger.error({ + domain: "security.flow", + event: "otp.request_cancel_failed", + message: "Failed to cancel OTP request", + ids: { request_id: request.requestId }, + error, + }); } onDone(); }; diff --git a/src/components/dialog/terminal/TranslationDialog.tsx b/src/components/dialog/terminal/TranslationDialog.tsx index 53219fe8..3a95abd6 100644 --- a/src/components/dialog/terminal/TranslationDialog.tsx +++ b/src/components/dialog/terminal/TranslationDialog.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { MdCheck, MdContentCopy } from "react-icons/md"; @@ -11,6 +10,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useApp } from "@/context/AppContext"; +import { invoke } from "@/lib/invoke"; import type { TranslateResult } from "@/types/global"; interface TranslationDialogProps { diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index ccd2c2ea..442ad079 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -3,7 +3,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { openPath, openUrl } from "@tauri-apps/plugin-opener"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BiServer } from "react-icons/bi"; +import { BiExport, BiImport, BiServer } from "react-icons/bi"; import { GrUpgrade } from "react-icons/gr"; import { MdAdd, @@ -28,13 +28,13 @@ import { MdZoomIn, MdZoomOut, } from "react-icons/md"; -import { BiImport, BiExport } from "react-icons/bi"; import packageJson from "@/../package.json"; import { useApp } from "@/context/AppContext"; import { useTheme } from "@/context/ThemeContext"; import { useConfigTransfer } from "@/hooks/useConfigTransfer"; import { MOD } from "@/hooks/useGlobalShortcuts"; import { AVAILABLE_LANGUAGES } from "@/i18n"; +import { logger } from "@/lib/logger"; import { DEFAULT_TERMINAL_FONT_SIZE, decreaseTerminalFontSize, @@ -284,7 +284,12 @@ export default function Header({ const logDir = await appLogDir(); await openPath(logDir); } catch (error) { - console.error("Failed to open logs:", error); + logger.error({ + domain: "ui.error", + event: "logs.open_failed", + message: "Failed to open logs", + error, + }); } }, }, diff --git a/src/components/panel/ActiveSessions.tsx b/src/components/panel/ActiveSessions.tsx index 1b0dc61e..16705fca 100644 --- a/src/components/panel/ActiveSessions.tsx +++ b/src/components/panel/ActiveSessions.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { memo, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -6,6 +5,7 @@ import { MdClose, MdRefresh, MdSearch } from "react-icons/md"; import PanelHeader from "@/components/layout/PanelHeader"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { invoke } from "@/lib/invoke"; import type { SessionInfo } from "@/types/global"; interface ActiveSessionsProps { diff --git a/src/components/panel/CommandHistory.tsx b/src/components/panel/CommandHistory.tsx index c3f83217..7b390479 100644 --- a/src/components/panel/CommandHistory.tsx +++ b/src/components/panel/CommandHistory.tsx @@ -1,9 +1,9 @@ -import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { MdChevronRight } from "react-icons/md"; import PanelHeader from "@/components/layout/PanelHeader"; +import { invoke } from "@/lib/invoke"; interface CommandHistoryProps { onCommandSend: (command: string) => void; diff --git a/src/components/panel/QuickCommands.tsx b/src/components/panel/QuickCommands.tsx index d7a8db01..ee6fb3d2 100644 --- a/src/components/panel/QuickCommands.tsx +++ b/src/components/panel/QuickCommands.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -31,6 +30,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { invoke } from "@/lib/invoke"; import type { QuickCommand, QuickCommandCategory, QuickCommandsConfig } from "@/types/global"; import { openQuickCommand } from "../../lib/windowManager"; import VariablePromptDialog, { diff --git a/src/components/panel/SendCommandPanel.tsx b/src/components/panel/SendCommandPanel.tsx index 621b2e53..145cffbf 100644 --- a/src/components/panel/SendCommandPanel.tsx +++ b/src/components/panel/SendCommandPanel.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { MdSend } from "react-icons/md"; @@ -13,6 +12,7 @@ import { } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; +import { invoke } from "@/lib/invoke"; import { sendSessionInput } from "@/lib/sessionInput"; interface SerialSendPanelProps { diff --git a/src/components/panel/file-explorer/index.tsx b/src/components/panel/file-explorer/index.tsx index 009b2f3c..67f0fcfe 100644 --- a/src/components/panel/file-explorer/index.tsx +++ b/src/components/panel/file-explorer/index.tsx @@ -1,9 +1,7 @@ -import { invoke } from "@tauri-apps/api/core"; import { emit, listen } from "@tauri-apps/api/event"; import { downloadDir, join, tempDir } from "@tauri-apps/api/path"; import { open as openDialog, save as saveDialog } from "@tauri-apps/plugin-dialog"; import { openPath } from "@tauri-apps/plugin-opener"; - import { type ComponentProps, type KeyboardEvent as ReactKeyboardEvent, @@ -69,6 +67,8 @@ import { } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useApp } from "@/context/AppContext"; +import { invoke } from "@/lib/invoke"; +import { logger } from "@/lib/logger"; import { sendSessionInput } from "@/lib/sessionInput"; import { formatSize } from "@/lib/utils"; import { openAutoUpload } from "@/lib/windowManager"; @@ -229,7 +229,15 @@ export default function FileExplorer({ activeSessionId, activeSessionType }: Fil sessionId: session_id, localPath: local_path, remotePath: remote_path, - }).catch((err) => console.error("Auto upload failed", err)); + }).catch((err) => + logger.error({ + domain: "watcher.sync", + event: "auto_upload.failed", + message: "Auto upload failed", + ids: { session_id }, + error: err, + }), + ); } else { // Trigger the window openAutoUpload({ @@ -809,7 +817,13 @@ export default function FileExplorer({ activeSessionId, activeSessionType }: Fil } } } catch (e) { - console.error("Download failed", e); + logger.error({ + domain: "transfer.lifecycle", + event: "download.failed", + message: "Download failed", + ids: activeSessionId ? { session_id: activeSessionId } : undefined, + error: e, + }); } }; @@ -853,12 +867,24 @@ export default function FileExplorer({ activeSessionId, activeSessionType }: Fil remotePath, }); } catch (e) { - console.error("Upload failed", e); + logger.error({ + domain: "transfer.lifecycle", + event: "upload.failed", + message: "Upload failed", + ids: { session_id: activeSessionId }, + error: e, + }); pendingManualRefreshUploadsRef.current.delete(uploadKey); } } } catch (e) { - console.error("Upload selection failed", e); + logger.error({ + domain: "transfer.lifecycle", + event: "upload.selection_failed", + message: "Upload selection failed", + ids: { session_id: activeSessionId }, + error: e, + }); } }; @@ -878,7 +904,13 @@ export default function FileExplorer({ activeSessionId, activeSessionType }: Fil }); loadDirectory(currentPath); } catch (e) { - console.error("Upload folder failed", e); + logger.error({ + domain: "transfer.lifecycle", + event: "upload.folder_failed", + message: "Upload folder failed", + ids: { session_id: activeSessionId }, + error: e, + }); } }; @@ -895,7 +927,13 @@ export default function FileExplorer({ activeSessionId, activeSessionType }: Fil localPath, }); } catch (e) { - console.error("Download for open failed", e); + logger.error({ + domain: "transfer.lifecycle", + event: "download.open_failed", + message: "Download for open failed", + ids: { session_id: activeSessionId }, + error: e, + }); return; } diff --git a/src/components/panel/saved-connections/index.tsx b/src/components/panel/saved-connections/index.tsx index 2a6e686b..67d8c7db 100644 --- a/src/components/panel/saved-connections/index.tsx +++ b/src/components/panel/saved-connections/index.tsx @@ -362,7 +362,13 @@ export default function SavedConnections({ updateTabSession(tabId, sessionId); } catch (e) { const errorMessage = getErrorMessage(e); - logger.error(`Connection failed for "${conn.name}"`, e); + logger.error({ + domain: "session.lifecycle", + event: "connection.open_failed", + message: "Connection failed", + ids: { connection_id: conn.id }, + error: e, + }); toast.error(t("savedConnections.connectionFailed", { error: errorMessage })); markTabConnectionFailed(tabId, errorMessage); if (shouldPromptConnectionEditOnFailure(conn, errorMessage)) { @@ -718,7 +724,12 @@ export default function SavedConnections({ await invoke("reorder_items", { connections: connsUpdates, groups: groupsUpdates }); refreshConnections(); } catch (err) { - logger.error("Drag drop failed", err); + logger.error({ + domain: "ui.error", + event: "saved_connections.drag_drop_failed", + message: "Drag drop failed", + error: err, + }); } }; @@ -778,7 +789,12 @@ export default function SavedConnections({ } } } catch (err) { - logger.error("Drop to root failed", err); + logger.error({ + domain: "ui.error", + event: "saved_connections.drop_to_root_failed", + message: "Drop to root failed", + error: err, + }); } }; diff --git a/src/components/panel/security-auth/KeyManagementTab.tsx b/src/components/panel/security-auth/KeyManagementTab.tsx index 7d0d33d1..5ec37588 100644 --- a/src/components/panel/security-auth/KeyManagementTab.tsx +++ b/src/components/panel/security-auth/KeyManagementTab.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { open as openFileDialog } from "@tauri-apps/plugin-dialog"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -14,6 +13,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { invoke } from "@/lib/invoke"; import type { SshKey } from "@/types/global"; interface KeyManagementTabProps { diff --git a/src/components/panel/security-auth/OtpCodePanel.tsx b/src/components/panel/security-auth/OtpCodePanel.tsx index 1a8cf7ba..9083d5f6 100644 --- a/src/components/panel/security-auth/OtpCodePanel.tsx +++ b/src/components/panel/security-auth/OtpCodePanel.tsx @@ -1,10 +1,10 @@ -import { invoke } from "@tauri-apps/api/core"; import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { MdCheck, MdContentCopy, MdRefresh } from "react-icons/md"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { useOtpCode } from "@/hooks/useOtpCode"; +import { invoke } from "@/lib/invoke"; import type { OtpEntry } from "@/types/global"; interface OtpCodePanelProps { diff --git a/src/components/panel/security-auth/OtpManagementTab.tsx b/src/components/panel/security-auth/OtpManagementTab.tsx index 31871f21..2b61a0de 100644 --- a/src/components/panel/security-auth/OtpManagementTab.tsx +++ b/src/components/panel/security-auth/OtpManagementTab.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { open as openFileDialog } from "@tauri-apps/plugin-dialog"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -35,6 +34,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { invoke } from "@/lib/invoke"; import type { OtpEntry } from "@/types/global"; interface OtpManagementTabProps { diff --git a/src/components/panel/security-auth/PasswordManagementTab.tsx b/src/components/panel/security-auth/PasswordManagementTab.tsx index 3b49b9c7..4b35d94f 100644 --- a/src/components/panel/security-auth/PasswordManagementTab.tsx +++ b/src/components/panel/security-auth/PasswordManagementTab.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { MdAdd, MdDelete, MdEdit } from "react-icons/md"; @@ -13,6 +12,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { invoke } from "@/lib/invoke"; import type { SavedPassword } from "@/types/global"; interface PasswordManagementTabProps { diff --git a/src/components/sessions/SshForm.tsx b/src/components/sessions/SshForm.tsx index bc7a4f34..c76e820e 100644 --- a/src/components/sessions/SshForm.tsx +++ b/src/components/sessions/SshForm.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -21,6 +20,7 @@ import { } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { invoke } from "@/lib/invoke"; import type { OtpEntry, ProxyConfig, SavedPassword, SshKey } from "@/types/global"; interface SshFormProps { diff --git a/src/components/settings/AppearanceTab.tsx b/src/components/settings/AppearanceTab.tsx index 3b77dd07..d626a769 100644 --- a/src/components/settings/AppearanceTab.tsx +++ b/src/components/settings/AppearanceTab.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { MdAdd, MdClose } from "react-icons/md"; @@ -11,6 +10,7 @@ import { SelectValue, } from "@/components/ui/select"; import { useApp } from "@/context/AppContext"; +import { invoke } from "@/lib/invoke"; import { DEFAULT_TERMINAL_FONT_SIZE, MAX_TERMINAL_FONT_SIZE, diff --git a/src/components/settings/GeneralTab.tsx b/src/components/settings/GeneralTab.tsx index 854669f2..fb52d007 100644 --- a/src/components/settings/GeneralTab.tsx +++ b/src/components/settings/GeneralTab.tsx @@ -1,12 +1,21 @@ import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; import { SelectItem } from "@/components/ui/select"; import { useApp } from "@/context/AppContext"; +import { useConfigTransfer } from "@/hooks/useConfigTransfer"; import { AVAILABLE_LANGUAGES } from "@/i18n"; -import { SettingRow, SettingSection, SettingSelect, SettingSwitch } from "./SettingFormItems"; +import { + SettingFieldGrid, + SettingRow, + SettingSection, + SettingSelect, + SettingSwitch, +} from "./SettingFormItems"; export function GeneralTab() { const { t, i18n } = useTranslation(); const { appSettings, updateAppSettings, updateUi } = useApp(); + const { handleExportDiagnostics, handleOpenLogs } = useConfigTransfer(); return (
@@ -56,6 +65,67 @@ export function GeneralTab() { /> + + + + + updateAppSettings({ + diagnostics: { + ...appSettings.diagnostics, + level: level as typeof appSettings.diagnostics.level, + }, + }) + } + > + {t("settings.logLevelWarn")} + {t("settings.logLevelInfo")} + {t("settings.logLevelDebug")} + + + + updateAppSettings({ + diagnostics: { + ...appSettings.diagnostics, + retention_days: Number(retentionDays), + }, + }) + } + > + {[3, 7, 14, 30].map((days) => ( + + {days} {t("common.days")} + + ))} + + + + + + + + + + +
); } diff --git a/src/components/terminal/XTerminal.tsx b/src/components/terminal/XTerminal.tsx index bae55e82..ce9e9688 100644 --- a/src/components/terminal/XTerminal.tsx +++ b/src/components/terminal/XTerminal.tsx @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "@/lib/invoke"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { openUrl } from "@tauri-apps/plugin-opener"; import { FitAddon } from "@xterm/addon-fit"; @@ -17,10 +17,11 @@ import { useTerminalSearch } from "@/hooks/useTerminalSearch"; import { useTerminalSettings } from "@/hooks/useTerminalSettings"; import { readClipboardText } from "@/lib/clipboard"; import { hexLuminance } from "@/lib/keywordHighlightPresets"; +import { logger } from "@/lib/logger"; import { listenSessionInputPreview, - sendSessionInput, type SessionInputPreview, + sendSessionInput, } from "@/lib/sessionInput"; import { applyTerminalInputData, @@ -330,7 +331,14 @@ export default function XTerminal({ if (!isAllowedLinkUri(uri)) return; removeLinkPopup(); - openUrl(uri).catch((err: unknown) => console.error("Failed to open link:", err)); + openUrl(uri).catch((err: unknown) => + logger.error({ + domain: "ui.error", + event: "terminal.link_open_failed", + message: "Failed to open link", + error: err, + }), + ); }; const oscLinkHandler: ILinkHandler = { @@ -1059,10 +1067,7 @@ export default function XTerminal({ ? t("terminal.largeOutputProtectionActive") : t("terminal.largeOutputProtectionRecovered")} -
+
{t( performanceOverlay === "overloaded" ? "terminal.largeOutputProtectionActiveDetail" diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index 54e0e5ec..3a7e3871 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -37,7 +37,7 @@ import type { UiConfig, } from "@/types/global"; import { invoke } from "../lib/invoke"; -import { logger } from "../lib/logger"; +import { logger, setLoggerLevel } from "../lib/logger"; import { DEFAULT_TERMINAL_FONT_SIZE } from "../lib/terminalFontSize"; interface AppContextType { @@ -217,6 +217,10 @@ const DEFAULT_APP_SETTINGS: AppSettings = { default_editor: "", recording_path: "", }, + diagnostics: { + level: "info", + retention_days: 7, + }, ui: { open_tabs: [], left_width: 256, @@ -285,6 +289,7 @@ export function AppProvider({ children }: { children: ReactNode }) { .then((cfg) => { appSettingsRef.current = cfg; setAppSettings(cfg); + setLoggerLevel(cfg.diagnostics.level); appSettingsLoaded.current = true; setSettingsLoaded(true); if (cfg.security?.enable_screen_lock) { @@ -309,11 +314,17 @@ export function AppProvider({ children }: { children: ReactNode }) { const nextUpdates = typeof updates === "function" ? updates(prev) : updates; const next = { ...prev, ...nextUpdates }; appSettingsRef.current = next; + setLoggerLevel(next.diagnostics.level); if (appSettingsLoaded.current) { if (appSettingsSaveTimerRef.current) clearTimeout(appSettingsSaveTimerRef.current); appSettingsSaveTimerRef.current = setTimeout(() => { invoke("save_app_settings", { settings: next }).catch((e) => - logger.error("Failed to save app settings", e), + logger.error({ + domain: "settings.persistence", + event: "settings.save_failed", + message: "Failed to save app settings", + error: e, + }), ); }, 500); } @@ -344,7 +355,12 @@ export function AppProvider({ children }: { children: ReactNode }) { setSavedConnections(saved); setSavedGroups(groups); } catch (e) { - logger.error("Failed to fetch connections", e); + logger.error({ + domain: "ui.error", + event: "connections.fetch_failed", + message: "Failed to fetch connections", + error: e, + }); } }, []); @@ -750,7 +766,17 @@ export function AppProvider({ children }: { children: ReactNode }) { invoke("create_ssh_session", { connectionId: cid }) .then((sessionId) => updatePaneSession(tab.id, pane.id, sessionId)) .catch((e) => { - logger.error(`Restore SSH failed for ${pane.name}`, e); + logger.error({ + domain: "session.lifecycle", + event: "session.restore_failed", + message: "Restore SSH failed", + ids: pane.connectionId ? { connection_id: pane.connectionId } : undefined, + data: { + session_type: "SSH", + pane_id: pane.id, + }, + error: e, + }); markPaneConnectionFailed(tab.id, pane.id, getErrorMessage(e)); }); break; @@ -758,7 +784,16 @@ export function AppProvider({ children }: { children: ReactNode }) { invoke("create_local_session", { connectionId: cid || null }) .then((sessionId) => updatePaneSession(tab.id, pane.id, sessionId)) .catch((e) => { - logger.error("Restore Local failed", e); + logger.error({ + domain: "session.lifecycle", + event: "session.restore_failed", + message: "Restore Local failed", + data: { + session_type: "Local", + pane_id: pane.id, + }, + error: e, + }); markPaneConnectionFailed(tab.id, pane.id, getErrorMessage(e)); }); break; @@ -770,7 +805,17 @@ export function AppProvider({ children }: { children: ReactNode }) { invoke("create_telnet_session", { connectionId: cid }) .then((sessionId) => updatePaneSession(tab.id, pane.id, sessionId)) .catch((e) => { - logger.error(`Restore Telnet failed for ${pane.name}`, e); + logger.error({ + domain: "session.lifecycle", + event: "session.restore_failed", + message: "Restore Telnet failed", + ids: pane.connectionId ? { connection_id: pane.connectionId } : undefined, + data: { + session_type: "Telnet", + pane_id: pane.id, + }, + error: e, + }); markPaneConnectionFailed(tab.id, pane.id, getErrorMessage(e)); }); break; @@ -782,7 +827,17 @@ export function AppProvider({ children }: { children: ReactNode }) { invoke("create_serial_session", { connectionId: cid }) .then((sessionId) => updatePaneSession(tab.id, pane.id, sessionId)) .catch((e) => { - logger.error(`Restore Serial failed for ${pane.name}`, e); + logger.error({ + domain: "session.lifecycle", + event: "session.restore_failed", + message: "Restore Serial failed", + ids: pane.connectionId ? { connection_id: pane.connectionId } : undefined, + data: { + session_type: "Serial", + pane_id: pane.id, + }, + error: e, + }); markPaneConnectionFailed(tab.id, pane.id, getErrorMessage(e)); }); break; @@ -864,11 +919,7 @@ export function AppProvider({ children }: { children: ReactNode }) { ], ); - return ( - - {children} - - ); + return {children}; } /** Hook to access AppContext. Throws if used outside AppProvider. */ diff --git a/src/context/ChildAppProvider.tsx b/src/context/ChildAppProvider.tsx index 7096b1ff..d1cf3c59 100644 --- a/src/context/ChildAppProvider.tsx +++ b/src/context/ChildAppProvider.tsx @@ -3,7 +3,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro import type { AppSettings, Group, SavedConnection, UiConfig } from "@/types/global"; import i18n from "../i18n"; import { invoke } from "../lib/invoke"; -import { logger } from "../lib/logger"; +import { logger, setLoggerLevel } from "../lib/logger"; import { DEFAULT_TERMINAL_FONT_SIZE } from "../lib/terminalFontSize"; import { AppContext } from "./AppContext"; @@ -80,6 +80,10 @@ const DEFAULT_APP_SETTINGS: AppSettings = { default_editor: "", recording_path: "", }, + diagnostics: { + level: "info", + retention_days: 7, + }, ui: { open_tabs: [], left_width: 256, @@ -121,6 +125,7 @@ export function ChildAppProvider({ children }: { children: ReactNode }) { invoke("get_app_settings") .then((cfg) => { setAppSettings(cfg); + setLoggerLevel(cfg.diagnostics.level); loaded.current = true; setSettingsLoaded(true); if (cfg.ui?.language && cfg.ui.language !== i18n.language) { @@ -142,11 +147,17 @@ export function ChildAppProvider({ children }: { children: ReactNode }) { setAppSettings((prev) => { const nextUpdates = typeof updates === "function" ? updates(prev) : updates; const next = { ...prev, ...nextUpdates }; + setLoggerLevel(next.diagnostics.level); if (loaded.current) { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { invoke("save_app_settings", { settings: next }).catch((e) => - logger.error("Failed to save app settings", e), + logger.error({ + domain: "settings.persistence", + event: "settings.save_failed", + message: "Failed to save app settings", + error: e, + }), ); emit("settings-changed", next).catch(() => {}); }, 500); @@ -226,9 +237,5 @@ export function ChildAppProvider({ children }: { children: ReactNode }) { ], ); - return ( - - {children} - - ); + return {children}; } diff --git a/src/hooks/useCommandHistory.ts b/src/hooks/useCommandHistory.ts index f19de323..07bdff10 100644 --- a/src/hooks/useCommandHistory.ts +++ b/src/hooks/useCommandHistory.ts @@ -1,6 +1,6 @@ -import { invoke } from "@tauri-apps/api/core"; import type { Terminal } from "@xterm/xterm"; import { useCallback, useEffect, useRef, useState } from "react"; +import { invoke } from "@/lib/invoke"; import { canSuggestFromTracker, getTrackedCommand, diff --git a/src/hooks/useConfigTransfer.tsx b/src/hooks/useConfigTransfer.tsx index 2ae7666e..e7a1e4a5 100644 --- a/src/hooks/useConfigTransfer.tsx +++ b/src/hooks/useConfigTransfer.tsx @@ -1,4 +1,6 @@ +import { appLogDir } from "@tauri-apps/api/path"; import { open as openFileDialog, save as saveFileDialog } from "@tauri-apps/plugin-dialog"; +import { openPath } from "@tauri-apps/plugin-opener"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -43,7 +45,12 @@ export function useConfigTransfer() { await invoke("export_config", { outputPath: path }); toast.success(t("settings.exportSuccess")); } catch (error) { - logger.error("Export config failed", error); + logger.error({ + domain: "settings.persistence", + event: "config.export_failed", + message: "Export config failed", + error, + }); toast.error(`${t("settings.exportFailed")}: ${error}`); } }; @@ -62,11 +69,53 @@ export function useConfigTransfer() { await invoke("import_config", { filePath: path }); toast.success(t("settings.importSuccess")); } catch (error) { - logger.error("Import config failed", error); + logger.error({ + domain: "settings.persistence", + event: "config.import_failed", + message: "Import config failed", + error, + }); toast.error(`${t("settings.importFailed")}: ${error}`); } }; + const handleOpenLogs = async () => { + try { + const logDir = await appLogDir(); + await openPath(logDir); + } catch (error) { + logger.error({ + domain: "ui.error", + event: "logs.open_failed", + message: "Failed to open logs", + error, + }); + toast.error(t("settings.openLogsFailed")); + } + }; + + const handleExportDiagnostics = async () => { + const path = await saveFileDialog({ + filters: [{ name: "Dragonfly Diagnostics", extensions: ["zip"] }], + defaultPath: "dragonfly-diagnostics.zip", + }); + + if (!path) return; + + try { + await invoke("export_diagnostics", { outputPath: path }); + toast.success(t("settings.exportDiagnosticsSuccess")); + } catch (error) { + logger.error({ + domain: "settings.persistence", + event: "diagnostics.export_failed", + message: "Export diagnostics failed", + error, + }); + toast.error(`${t("settings.exportDiagnosticsFailed")}: ${error}`); + } + }; + const passwordAlert = ( @@ -94,6 +143,8 @@ export function useConfigTransfer() { return { handleExport, handleImport, + handleOpenLogs, + handleExportDiagnostics, passwordAlert, }; } diff --git a/src/hooks/useOtpCode.ts b/src/hooks/useOtpCode.ts index 96a2f476..3f31c1d0 100644 --- a/src/hooks/useOtpCode.ts +++ b/src/hooks/useOtpCode.ts @@ -1,5 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useRef, useState } from "react"; +import { invoke } from "@/lib/invoke"; import type { OtpCodeResult } from "@/types/global"; interface UseOtpCodeOptions { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 12cdab16..7bb830df 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -590,6 +590,8 @@ "customEngines": "Search Engines", "dataManagement": "Data Management", "dataManagementDesc": "Import or export all application configuration data. Exported files are encrypted with the master password.", + "diagnostics": "Diagnostics", + "diagnosticsDesc": "Manage local structured diagnostic logs for troubleshooting and support.", "defaultEditor": "Default Editor", "defaultEditorDesc": "The program to use for local opening of remote files. Leave empty to use the system default application.", "defaultEncoding": "Default Terminal Encoding", @@ -638,6 +640,13 @@ "hostKeyPolicyDesc": "How to handle unknown SSH host keys during connection.", "hostKeyPrompt": "Prompt (Ask user)", "hostKeyStrict": "Strict (Reject unknown)", + "logLevel": "Log Level", + "logLevelDebug": "Debug", + "logLevelDesc": "Control how much detail is written to local diagnostic logs.", + "logLevelInfo": "Info", + "logLevelWarn": "Warn", + "logRetention": "Log Retention", + "logRetentionDesc": "Older logs beyond this window are removed automatically.", "idleLockMinutes": "Idle Lock (minutes)", "idleLockMinutesDesc": "Automatically lock the application after X minutes of inactivity. 0 to disable.", "importConfig": "Import Config", @@ -690,6 +699,9 @@ "noKeyRequired": "No key required", "noKeys": "No SSH keys configured.", "notConfigured": "Not Configured", + "openLogs": "Open Log Folder", + "openLogsDesc": "Open the local log folder in your system file manager.", + "openLogsFailed": "Failed to open log folder", "passphrase": "Passphrase (optional)", "policyAccept": "Accept (Auto-add)", "policyPrompt": "Prompt (Ask user)", @@ -768,6 +780,10 @@ "transfer": "Transfer", "transferBufferSize": "Buffer Size (KB)", "transferBufferSizeDesc": "Read/write buffer size in kilobytes for file transfers.", + "exportDiagnostics": "Export Diagnostics", + "exportDiagnosticsDesc": "Export recent logs, a runtime summary, and a manifest for troubleshooting.", + "exportDiagnosticsSuccess": "Diagnostics bundle exported successfully.", + "exportDiagnosticsFailed": "Diagnostics export failed", "translation": "Translation", "translationProvider": "Provider", "translationProviders": "Translation Providers", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 17f9cacb..ba9d6191 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -590,6 +590,8 @@ "customEngines": "搜索引擎配置", "dataManagement": "数据管理", "dataManagementDesc": "导入或导出应用程序的全部配置数据。导出文件使用主密码加密,请妥善保管。", + "diagnostics": "诊断日志", + "diagnosticsDesc": "管理本地结构化诊断日志,用于问题排查与反馈支持。", "defaultEditor": "默认编辑器", "defaultEditorDesc": "用于本地打开远程文件的程序。留空则使用系统默认应用程序。", "defaultEncoding": "默认终端编码", @@ -638,6 +640,13 @@ "hostKeyPolicyDesc": "连接期间处理未知 SSH 主机密钥的方法。", "hostKeyPrompt": "提示 (询问用户)", "hostKeyStrict": "严格 (拒绝未知)", + "logLevel": "日志级别", + "logLevelDebug": "调试", + "logLevelDesc": "控制本地诊断日志记录的详细程度。", + "logLevelInfo": "信息", + "logLevelWarn": "警告", + "logRetention": "日志保留时长", + "logRetentionDesc": "超过该时长的旧日志会被自动清理。", "idleLockMinutes": "空闲锁定 (分钟)", "idleLockMinutesDesc": "在 X 分钟无活动后自动锁定应用程序。设置为 0 以禁用。", "importConfig": "导入配置", @@ -690,6 +699,9 @@ "noKeyRequired": "无需密钥", "noKeys": "暂无 SSH 密钥。", "notConfigured": "未配置", + "openLogs": "打开日志目录", + "openLogsDesc": "在系统文件管理器中打开本地日志目录。", + "openLogsFailed": "打开日志目录失败", "passphrase": "密码短语(可选)", "policyAccept": "接受 (自动添加)", "policyPrompt": "提示 (询问用户)", @@ -768,6 +780,10 @@ "transfer": "传输", "transferBufferSize": "缓冲区大小 (KB)", "transferBufferSizeDesc": "文件传输的读写缓冲区大小(千字节)。", + "exportDiagnostics": "导出诊断包", + "exportDiagnosticsDesc": "导出最近的日志、运行时摘要和清单文件,便于定位问题。", + "exportDiagnosticsSuccess": "诊断包已成功导出。", + "exportDiagnosticsFailed": "导出诊断包失败", "translation": "翻译", "translationProvider": "翻译提供商", "translationProviders": "翻译服务商", diff --git a/src/lib/invoke.ts b/src/lib/invoke.ts index 2c5c1b1e..8f95192a 100644 --- a/src/lib/invoke.ts +++ b/src/lib/invoke.ts @@ -9,10 +9,78 @@ import { logger } from "./logger"; * const sessions = await invoke("list_sessions"); */ export async function invoke(cmd: string, args?: Record): Promise { + const requestId = logger.createRequestId(); + const startedAt = performance.now(); + const argsSummary = summarizeInvokeArgs(args); + + logger.debug({ + domain: "tauri.invoke", + event: "command.start", + message: `Command "${cmd}" started`, + ids: { request_id: requestId }, + data: { + command: cmd, + args: argsSummary, + }, + }); + try { - return await tauriInvoke(cmd, args); + const result = await tauriInvoke(cmd, args); + logger.debug({ + domain: "tauri.invoke", + event: "command.success", + message: `Command "${cmd}" succeeded`, + ids: { request_id: requestId }, + data: { + command: cmd, + duration_ms: Math.round(performance.now() - startedAt), + }, + }); + return result; } catch (error) { - logger.error(`Command "${cmd}" failed`, error); + logger.error({ + domain: "tauri.invoke", + event: "command.error", + message: `Command "${cmd}" failed`, + ids: { request_id: requestId }, + data: { + command: cmd, + duration_ms: Math.round(performance.now() - startedAt), + args: argsSummary, + }, + error, + }); throw error; } } + +function summarizeInvokeArgs(args?: Record): unknown { + if (!args) return undefined; + return Object.fromEntries( + Object.entries(args).map(([key, value]) => [key, summarizeValue(value, 0)]), + ); +} + +function summarizeValue(value: unknown, depth: number): unknown { + if (value == null || typeof value === "boolean" || typeof value === "number") { + return value; + } + + if (typeof value === "string") { + return { type: "string", length: value.length }; + } + + if (Array.isArray(value)) { + return { type: "array", length: value.length }; + } + + if (typeof value === "object") { + const entries = Object.entries(value as Record); + if (depth >= 1) { + return { type: "object", keys: entries.map(([key]) => key).slice(0, 20) }; + } + return Object.fromEntries(entries.map(([key, item]) => [key, summarizeValue(item, depth + 1)])); + } + + return { type: typeof value }; +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts index e6fbf539..76983fe6 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -1,7 +1,39 @@ import { invoke as tauriInvoke } from "@tauri-apps/api/core"; +import type { DiagnosticsLogLevel } from "@/types/global"; type LogLevel = "debug" | "info" | "warn" | "error"; +type LogDomain = + | "app.lifecycle" + | "ui.action" + | "ui.error" + | "tauri.invoke" + | "settings.persistence" + | "session.lifecycle" + | "ssh.auth" + | "transfer.lifecycle" + | "watcher.sync" + | "security.flow" + | "updater.flow"; + +type StableLogIdKey = "session_id" | "connection_id" | "transfer_id" | "tunnel_id" | "request_id"; + +type LogIds = Partial>; + +export interface LogPayload { + domain: LogDomain; + event: string; + message: string; + ids?: LogIds; + data?: unknown; + error?: unknown; +} + +interface FrontendLogEntry extends LogPayload { + timestamp: string; + level: LogLevel; +} + const LOG_LEVELS: Record = { debug: 0, info: 1, @@ -9,84 +41,284 @@ const LOG_LEVELS: Record = { error: 3, }; -const MIN_LEVEL: LogLevel = import.meta.env.DEV ? "debug" : "warn"; +const DEFAULT_LEVEL: LogLevel = import.meta.env.DEV ? "debug" : "info"; +const BATCH_DELAY_MS = 250; +const MAX_BATCH_SIZE = 50; + +let minLevel: LogLevel = DEFAULT_LEVEL; +const queue: FrontendLogEntry[] = []; +let flushTimer: ReturnType | null = null; +let flushInFlight = false; +let lifecycleFlushRegistered = false; + +export function setLoggerLevel(level: DiagnosticsLogLevel): void { + minLevel = level; +} function shouldLog(level: LogLevel): boolean { - return LOG_LEVELS[level] >= LOG_LEVELS[MIN_LEVEL]; + return LOG_LEVELS[level] >= LOG_LEVELS[minLevel]; } -function formatMessage(level: LogLevel, message: string): string { - const now = new Date(); - const pad = (n: number, w = 2) => String(n).padStart(w, "0"); - const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(now.getMilliseconds(), 3)}`; - return `[${timestamp}] [${level.toUpperCase()}] ${message}`; +function createRequestId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `req-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; } -function serializeLogArg(arg: unknown): string { - if (arg instanceof Error) { - return arg.stack ?? `${arg.name}: ${arg.message}`; +function formatTimestamp(date = new Date()): string { + const pad = (value: number, width = 2) => String(value).padStart(width, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`; +} + +function formatConsoleLine(entry: FrontendLogEntry): string { + return `[${entry.timestamp}] [${entry.level.toUpperCase()}] [${entry.domain}/${entry.event}] ${entry.message}`; +} + +function normalizeError(error: unknown): unknown { + if (error instanceof Error) { + return { + name: error.name, + message_hash: hashString(error.message), + stack_hash: error.stack ? hashString(error.stack) : undefined, + }; + } + return error; +} + +function hashString(value: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +function normalizeKey(key: string): string { + return key + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} + +function isRedactedKey(key: string): boolean { + const normalized = normalizeKey(key); + return ( + normalized === "password" || + normalized === "secret" || + normalized === "token" || + normalized === "otp" || + normalized === "command" || + normalized === "content" || + normalized === "clipboard" || + normalized === "passphrase" || + normalized === "master_password" || + normalized.endsWith("_password") || + normalized.endsWith("_secret") || + normalized.endsWith("_token") || + normalized.endsWith("_otp") || + normalized.endsWith("_command") || + normalized.endsWith("_content") || + normalized.endsWith("_clipboard") || + normalized.endsWith("_passphrase") || + normalized.includes("private_key") || + normalized.includes("public_key") || + normalized.includes("key_data") || + normalized.includes("secret_key") || + (normalized.endsWith("_key") && !normalized.endsWith("_id")) + ); +} + +function isHostKey(key: string): boolean { + const normalized = normalizeKey(key); + return normalized === "host" || normalized.endsWith("_host"); +} + +function isUsernameKey(key: string): boolean { + const normalized = normalizeKey(key); + return normalized === "username" || normalized === "user" || normalized.endsWith("_username"); +} + +function isPathKey(key: string): boolean { + const normalized = normalizeKey(key); + return normalized === "path" || normalized === "cwd" || normalized.endsWith("_path"); +} + +function sanitizeString(key: string | undefined, value: string): unknown { + if (!key) return value; + if (isRedactedKey(key)) return "[REDACTED]"; + if (isHostKey(key)) return { type: "host", hash: hashString(value) }; + if (isUsernameKey(key)) return { type: "username", hash: hashString(value) }; + if (isPathKey(key)) { + const extension = (() => { + const lastDot = value.lastIndexOf("."); + if (lastDot <= value.lastIndexOf("/") || lastDot <= value.lastIndexOf("\\")) { + return undefined; + } + return value.slice(lastDot + 1); + })(); + return { + type: "path", + hash: hashString(value), + ...(extension ? { extension } : {}), + }; + } + return value; +} + +function sanitizeValue(value: unknown, key?: string): unknown { + if (value instanceof Error) { + return sanitizeValue(normalizeError(value), key); } - if (typeof arg === "string") { - return arg; + if (Array.isArray(value)) { + return value.map((item) => sanitizeValue(item, key)); } - if ( - typeof arg === "number" || - typeof arg === "boolean" || - typeof arg === "bigint" || - typeof arg === "symbol" || - arg == null - ) { - return String(arg); + if (typeof value === "string") { + return sanitizeString(key, value); } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([childKey, childValue]) => [ + childKey, + sanitizeValue(childValue, childKey), + ]), + ); + } + + return value; +} + +function normalizePayload(level: LogLevel, payload: LogPayload): FrontendLogEntry { + return { + ...payload, + timestamp: formatTimestamp(), + level, + ids: payload.ids ? (sanitizeValue(payload.ids, "ids") as LogIds) : undefined, + data: payload.data === undefined ? undefined : sanitizeValue(payload.data, "data"), + error: + payload.error === undefined + ? undefined + : sanitizeValue(normalizeError(payload.error), "error"), + }; +} + +function writeConsole(entry: FrontendLogEntry): void { + const line = formatConsoleLine(entry); + const extras = [entry.ids, entry.data, entry.error].filter((item) => item !== undefined); + switch (entry.level) { + case "debug": + console.debug(line, ...extras); + break; + case "info": + console.info(line, ...extras); + break; + case "warn": + console.warn(line, ...extras); + break; + case "error": + console.error(line, ...extras); + break; + } +} + +function scheduleFlush(): void { + if (flushTimer) return; + flushTimer = setTimeout(() => { + flushTimer = null; + void flushQueue(); + }, BATCH_DELAY_MS); +} + +async function flushQueue(): Promise { + if (flushInFlight) return; + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + if (queue.length === 0) return; + + flushInFlight = true; + const batch = queue.splice(0, MAX_BATCH_SIZE); + try { - return JSON.stringify(arg); - } catch { - return String(arg); + await tauriInvoke("append_frontend_logs", { entries: batch }); + } catch (error) { + console.error( + `[${formatTimestamp()}] [ERROR] [ui.error/logger.flush_failed] Failed to persist frontend log batch`, + error, + ); + } finally { + flushInFlight = false; + if (queue.length >= MAX_BATCH_SIZE) { + void flushQueue(); + } else if (queue.length > 0) { + scheduleFlush(); + } } } -function persistLog(level: LogLevel, message: string, args: unknown[]): void { - if (level !== "warn" && level !== "error") { - return; +function registerLifecycleFlush(): void { + if (lifecycleFlushRegistered) return; + if (typeof window === "undefined") return; + + const flush = () => { + void flushQueue(); + }; + + window.addEventListener("pagehide", flush); + window.addEventListener("beforeunload", flush); + + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") { + flush(); + } + }); } - const context = args.map(serializeLogArg).join("\n"); - - void tauriInvoke("write_log", { - level, - message, - context: context || null, - }).catch(() => {}); + lifecycleFlushRegistered = true; +} + +function emit(level: LogLevel, payload: LogPayload): void { + if (!shouldLog(level)) return; + + const entry = normalizePayload(level, payload); + writeConsole(entry); + queue.push(entry); + + if (level === "warn" || level === "error" || queue.length >= MAX_BATCH_SIZE) { + void flushQueue(); + } else { + scheduleFlush(); + } } -/** Level-filtered logger: debug/info/warn/error. Respects DEV vs prod min level. */ export const logger = { - debug(message: string, ...args: unknown[]) { - if (shouldLog("debug")) { - console.debug(formatMessage("debug", message), ...args); - } + debug(payload: LogPayload) { + emit("debug", payload); }, - info(message: string, ...args: unknown[]) { - if (shouldLog("info")) { - console.info(formatMessage("info", message), ...args); - } + info(payload: LogPayload) { + emit("info", payload); }, - warn(message: string, ...args: unknown[]) { - if (shouldLog("warn")) { - console.warn(formatMessage("warn", message), ...args); - persistLog("warn", message, args); - } + warn(payload: LogPayload) { + emit("warn", payload); }, - error(message: string, ...args: unknown[]) { - if (shouldLog("error")) { - console.error(formatMessage("error", message), ...args); - persistLog("error", message, args); - } + error(payload: LogPayload) { + emit("error", payload); }, + + flush(): Promise { + return flushQueue(); + }, + + createRequestId, }; + +registerLifecycleFlush(); diff --git a/src/lib/sessionInput.ts b/src/lib/sessionInput.ts index d3629d96..01f71d83 100644 --- a/src/lib/sessionInput.ts +++ b/src/lib/sessionInput.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "./invoke"; const SESSION_INPUT_PREVIEW_EVENT = "dragonfly:session-input-preview"; diff --git a/src/lib/updater.ts b/src/lib/updater.ts index 7e3790fc..00c3a740 100644 --- a/src/lib/updater.ts +++ b/src/lib/updater.ts @@ -1,5 +1,6 @@ import { relaunch } from "@tauri-apps/plugin-process"; import { check, type Update } from "@tauri-apps/plugin-updater"; +import { logger } from "@/lib/logger"; export type UpdateStatus = "idle" | "checking" | "available" | "downloading" | "ready" | "error"; @@ -17,15 +18,48 @@ export interface UpdateInfo { let cachedUpdate: Update | null = null; export async function checkForUpdate(): Promise { - const update = await check(); - if (!update) return null; + logger.info({ + domain: "updater.flow", + event: "update.check_started", + message: "Checking for updates", + }); - cachedUpdate = update; - return { - version: update.version, - date: update.date, - body: update.body, - }; + try { + const update = await check(); + if (!update) { + cachedUpdate = null; + logger.info({ + domain: "updater.flow", + event: "update.check_succeeded", + message: "No update available", + }); + return null; + } + + cachedUpdate = update; + logger.info({ + domain: "updater.flow", + event: "update.available", + message: "Update available", + data: { + version: update.version, + release_date: update.date, + }, + }); + return { + version: update.version, + date: update.date, + body: update.body, + }; + } catch (error) { + logger.error({ + domain: "updater.flow", + event: "update.check_failed", + message: "Update check failed", + error, + }); + throw error; + } } export async function downloadAndInstallUpdate( @@ -36,23 +70,59 @@ export async function downloadAndInstallUpdate( let downloaded = 0; let total = 0; - await cachedUpdate.downloadAndInstall((event) => { - switch (event.event) { - case "Started": - total = event.data.contentLength ?? 0; - onProgress?.({ downloaded: 0, total }); - break; - case "Progress": - downloaded += event.data.chunkLength; - onProgress?.({ downloaded, total }); - break; - case "Finished": - onProgress?.({ downloaded: total, total }); - break; - } + logger.info({ + domain: "updater.flow", + event: "update.download_started", + message: "Starting update download and install", + data: { + version: cachedUpdate.version, + }, }); + + try { + await cachedUpdate.downloadAndInstall((event) => { + switch (event.event) { + case "Started": + total = event.data.contentLength ?? 0; + onProgress?.({ downloaded: 0, total }); + break; + case "Progress": + downloaded += event.data.chunkLength; + onProgress?.({ downloaded, total }); + break; + case "Finished": + onProgress?.({ downloaded: total, total }); + logger.info({ + domain: "updater.flow", + event: "update.download_finished", + message: "Update download finished", + data: { + version: cachedUpdate?.version, + byte_size: total, + }, + }); + break; + } + }); + } catch (error) { + logger.error({ + domain: "updater.flow", + event: "update.install_failed", + message: "Update install failed", + data: { + version: cachedUpdate.version, + }, + error, + }); + throw error; + } } export async function relaunchApp(): Promise { + logger.info({ + domain: "updater.flow", + event: "update.relaunch_requested", + message: "Relaunching app after update", + }); await relaunch(); } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index b2c300c2..ed11ad95 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,6 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; +import { logger } from "./logger"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -28,7 +29,12 @@ export function parseJsonSearchParam(value: string | null): T | null { // URLSearchParams.get() already returns a decoded value. return JSON.parse(value) as T; } catch (error) { - console.error("Failed to parse JSON search param", error); + logger.error({ + domain: "ui.error", + event: "url_param.parse_failed", + message: "Failed to parse JSON search param", + error, + }); return null; } } diff --git a/src/pages/FileUploadPage.tsx b/src/pages/FileUploadPage.tsx index dcc584db..e860a310 100644 --- a/src/pages/FileUploadPage.tsx +++ b/src/pages/FileUploadPage.tsx @@ -1,10 +1,11 @@ -import { invoke } from "@tauri-apps/api/core"; import { emit } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useTranslation } from "react-i18next"; import { MdCloudSync } from "react-icons/md"; import ChildWindowHeader from "@/components/layout/ChildWindowHeader"; import { Button } from "@/components/ui/button"; +import { invoke } from "@/lib/invoke"; +import { logger } from "@/lib/logger"; import { parseJsonSearchParam } from "@/lib/utils"; export interface AutoUploadDialogData { @@ -41,7 +42,13 @@ export default function AutoUploadPage() { remotePath: data.remotePath, }); } catch (e) { - console.error("Upload failed", e); + logger.error({ + domain: "transfer.lifecycle", + event: "upload.auto_upload_failed", + message: "Upload failed", + ids: { session_id: data.sessionId }, + error: e, + }); } handleClose(); diff --git a/src/pages/NewSessionPage.tsx b/src/pages/NewSessionPage.tsx index fbcccd72..7ddac1ae 100644 --- a/src/pages/NewSessionPage.tsx +++ b/src/pages/NewSessionPage.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { emit } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -21,6 +20,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; +import { invoke } from "@/lib/invoke"; import type { Group, OtpEntry, ProxyConfig, SavedConnection } from "@/types/global"; export default function NewSessionPage() { @@ -343,12 +343,9 @@ export default function NewSessionPage() { username, auth: { mode: authType, - password_id: - authType === "password" ? (passwordId || "") : undefined, + password_id: authType === "password" ? passwordId || "" : undefined, password: - authType === "password" - ? password || (hasPassword ? undefined : "") - : undefined, + authType === "password" ? password || (hasPassword ? undefined : "") : undefined, key_id: authType === "key" && keyId ? keyId : undefined, otp_id: otpId || undefined, auto_fill_otp: otpId ? autoFillOtp : undefined, diff --git a/src/pages/QuickCommandPage.tsx b/src/pages/QuickCommandPage.tsx index 09cac4bb..62dd9408 100644 --- a/src/pages/QuickCommandPage.tsx +++ b/src/pages/QuickCommandPage.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { emit } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { type CSSProperties, useEffect, useState } from "react"; @@ -19,6 +18,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Switch } from "@/components/ui/switch"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; +import { invoke } from "@/lib/invoke"; import { parseJsonSearchParam } from "@/lib/utils"; import type { QuickCommand, QuickCommandCategory } from "@/types/global"; diff --git a/src/types/global.d.ts b/src/types/global.d.ts index ed2e0bcd..54d82026 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -452,6 +452,13 @@ export interface TransferSettings { recording_path: string; } +export type DiagnosticsLogLevel = "warn" | "info" | "debug"; + +export interface DiagnosticsSettings { + level: DiagnosticsLogLevel; + retention_days: number; +} + export interface TunnelConfig { id: string; name: string; @@ -483,6 +490,7 @@ export interface AppSettings { terminal: TerminalSettings; interaction: InteractionSettings; transfer: TransferSettings; + diagnostics: DiagnosticsSettings; ui: UiConfig; }