diff --git a/Cargo.lock b/Cargo.lock index f6791834d..df9404a08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1220,6 +1220,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -4596,6 +4605,7 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", "tower", "tracing", + "tracing-appender", "tracing-log", "tracing-subscriber", "unicode-segmentation", @@ -6717,6 +6727,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -7250,6 +7266,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -8230,7 +8259,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b0153683f..559315d5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,7 @@ schemars = "1.2" tracing = { version = "0.1", default-features = false } tracing-log = "0.2" tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "ansi", "env-filter"] } +tracing-appender = "0.2" prometheus = { version = "0.14", default-features = false } urlencoding = "2.1" thiserror = "2.0" diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 525ac7e40..ee198c50a 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4547,6 +4547,7 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", "tower", "tracing", + "tracing-appender", "tracing-log", "tracing-subscriber", "unicode-segmentation", @@ -6754,6 +6755,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -7742,6 +7749,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" diff --git a/app/src-tauri/permissions/allow-core-process.toml b/app/src-tauri/permissions/allow-core-process.toml index d6a2bf3ac..5b0455480 100644 --- a/app/src-tauri/permissions/allow-core-process.toml +++ b/app/src-tauri/permissions/allow-core-process.toml @@ -62,5 +62,12 @@ allow = [ "gmail_trash", "gmail_add_label", "gmail_find_linkedin_profile_url", + # Surface the embedded core's daily-rotated log directory + # (`/logs/`) so the Settings → Developer Options panel can + # show users the path and reveal it in the platform file manager when + # collecting support bundles. Read-only; no writes occur in the + # backing commands. + "logs_folder_path", + "reveal_logs_folder", ] deny = [] diff --git a/app/src-tauri/src/file_logging.rs b/app/src-tauri/src/file_logging.rs new file mode 100644 index 000000000..2aedba787 --- /dev/null +++ b/app/src-tauri/src/file_logging.rs @@ -0,0 +1,139 @@ +//! Tauri shell side of file-based logging. +//! +//! Resolves the OpenHuman data directory the same way the core does +//! (`~/.openhuman` or `OPENHUMAN_WORKSPACE` override) and hands it to +//! [`openhuman_core::core::logging::init_for_embedded`], which installs a +//! daily-rotated file appender so packaged GUI builds — where stderr is +//! invisible — still produce a log users can share for support. +//! +//! Both the shell's `log::*` calls (via the `tracing_log::LogTracer` bridge) +//! and the embedded core's `tracing::*` events funnel into the same file. + +use std::path::PathBuf; + +use openhuman_core::core::logging::{self, log_directory}; + +/// Initialize logging for the Tauri shell + embedded core. Idempotent and +/// safe to call from any startup position; the underlying `Once` guard means +/// the first caller's data dir wins. +/// +/// Verbosity defaults to `info` (or `debug` when `OPENHUMAN_VERBOSE=1`); the +/// `RUST_LOG` env var continues to override both. +pub fn init() { + let data_dir = resolve_data_dir(); + let verbose = std::env::var("OPENHUMAN_VERBOSE") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + logging::init_for_embedded(&data_dir, verbose); +} + +/// Resolve the directory used to host `/logs/`. Mirrors the core's +/// own resolution so log files sit next to `active_user.toml`, the per-user +/// `users/` tree, and the CEF caches a support engineer would also need. +/// +/// If `default_root_openhuman_dir` fails (very unusual — it requires +/// `dirs::home_dir` to return `None`), falls back to `/openhuman` +/// rather than a relative `.openhuman` whose final location depends on the +/// shell's CWD at launch time. +fn resolve_data_dir() -> PathBuf { + if let Ok(workspace) = std::env::var("OPENHUMAN_WORKSPACE") { + if !workspace.is_empty() { + return PathBuf::from(workspace); + } + } + openhuman_core::openhuman::config::default_root_openhuman_dir().unwrap_or_else(|err| { + eprintln!( + "[file_logging] default_root_openhuman_dir failed ({err}); falling back to temp dir" + ); + std::env::temp_dir().join("openhuman") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Lock around env-var mutation. Cargo runs unit tests in parallel + /// threads in the same process, so concurrent `set_var` / `remove_var` + /// can race; the lock keeps the env stable for each test's duration. + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + #[test] + fn resolve_data_dir_honors_workspace_override() { + let _guard = ENV_LOCK.lock().unwrap(); + let prior = std::env::var("OPENHUMAN_WORKSPACE").ok(); + std::env::set_var("OPENHUMAN_WORKSPACE", "/tmp/openhuman-test-override"); + let dir = resolve_data_dir(); + assert_eq!(dir, PathBuf::from("/tmp/openhuman-test-override")); + match prior { + Some(v) => std::env::set_var("OPENHUMAN_WORKSPACE", v), + None => std::env::remove_var("OPENHUMAN_WORKSPACE"), + } + } + + #[test] + fn resolve_data_dir_ignores_empty_workspace() { + let _guard = ENV_LOCK.lock().unwrap(); + let prior = std::env::var("OPENHUMAN_WORKSPACE").ok(); + std::env::set_var("OPENHUMAN_WORKSPACE", ""); + // Empty string must NOT short-circuit — fall through to the + // default resolver so the user's real `~/.openhuman` is used. + let dir = resolve_data_dir(); + assert_ne!(dir, PathBuf::from("")); + assert!(dir.is_absolute(), "expected absolute fallback, got {dir:?}"); + match prior { + Some(v) => std::env::set_var("OPENHUMAN_WORKSPACE", v), + None => std::env::remove_var("OPENHUMAN_WORKSPACE"), + } + } + + #[test] + fn logs_folder_path_returns_none_pre_init() { + // `init()` is `Once`-guarded across the whole process, so in unit + // tests where the embedded subscriber hasn't been installed, + // `logs_folder_path` should return `None` rather than a stale path. + // (When run alongside a test that *did* call `init`, the function + // is allowed to return Some — assert the type signature only.) + let result = logs_folder_path(); + let _: Option = result; + } + + #[test] + fn reveal_logs_folder_errors_when_uninitialized() { + // If logging hasn't been initialized, the command must surface a + // typed error so the UI can show it instead of silently launching + // an `open` against an empty path. + if openhuman_core::core::logging::log_directory().is_none() { + let err = reveal_logs_folder().expect_err("must error pre-init"); + assert!(err.contains("not initialized"), "unexpected error: {err}"); + } + } +} + +/// Tauri command — return the absolute path to the active log directory, or +/// `None` if logging hasn't been initialized in embedded mode (shouldn't +/// happen at runtime; guard for tests). +#[tauri::command] +pub fn logs_folder_path() -> Option { + log_directory().map(|p| p.display().to_string()) +} + +/// Tauri command — open the platform file manager at the log directory so a +/// user can grab today's log file and send it to support. +#[tauri::command] +pub fn reveal_logs_folder() -> Result<(), String> { + let dir = log_directory().ok_or_else(|| "log directory not initialized".to_string())?; + + #[cfg(target_os = "macos")] + let result = std::process::Command::new("open").arg(dir).spawn(); + + #[cfg(target_os = "windows")] + let result = std::process::Command::new("explorer").arg(dir).spawn(); + + #[cfg(target_os = "linux")] + let result = std::process::Command::new("xdg-open").arg(dir).spawn(); + + result + .map(|_| ()) + .map_err(|e| format!("failed to open log directory {}: {e}", dir.display())) +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index fe79d2e94..873910ade 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod core_process; mod core_rpc; mod dictation_hotkeys; mod discord_scanner; +mod file_logging; mod gmessages_scanner; mod imessage_scanner; #[cfg(target_os = "macos")] @@ -1119,10 +1120,13 @@ pub fn run() { let daemon_mode = is_daemon_mode(); - let default_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()); - let _ = env_logger::Builder::new() - .parse_filters(&default_filter) - .try_init(); + // Install the unified tracing subscriber + daily-rotated file appender + // before any other startup work so CEF preflight failures, sentry + // smoke-test events, and the rest of `run()` are captured in + // `/logs/openhuman-YYYY-MM-DD.log`. The shell's `log::*` calls + // are bridged into the same subscriber via `tracing_log::LogTracer`, + // replacing the previous stderr-only `env_logger`. + file_logging::init(); // The vendored tauri-cef dev-server proxy builds a reqwest 0.13 client // (see vendor/tauri-cef/crates/tauri/src/protocol/tauri.rs) which calls @@ -1655,7 +1659,9 @@ pub fn run() { activate_main_window, native_notifications::show_native_notification, mascot_window_show, - mascot_window_hide + mascot_window_hide, + file_logging::reveal_logs_folder, + file_logging::logs_folder_path ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx index 6b97491f0..c89de8e22 100644 --- a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx +++ b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react'; +import { invoke, isTauri } from '@tauri-apps/api/core'; +import { useEffect, useState } from 'react'; import { triggerSentryTestEvent } from '../../../services/analytics'; import { APP_ENVIRONMENT } from '../../../utils/config'; @@ -249,6 +250,61 @@ const SentryTestRow = () => { ); }; +// Surfaces the on-disk log folder so users running into "stuck on +// Initializing OpenHuman..." (and similar startup issues) can grab today's +// `openhuman-YYYY-MM-DD.log` and send it to support without hunting through +// `~/.openhuman/logs/`. Invokes the `reveal_logs_folder` Tauri command which +// `open`/`explorer`/`xdg-open`s the directory in the platform file manager. +const LogsFolderRow = () => { + const [path, setPath] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isTauri()) return; + invoke('logs_folder_path') + .then(p => setPath(p ?? null)) + .catch(err => { + setError(err instanceof Error ? err.message : String(err)); + }); + }, []); + + const onClick = async () => { + setError(null); + try { + await invoke('reveal_logs_folder'); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + if (!isTauri()) return null; + + return ( +
+
+
+
App logs
+
+ Open the folder containing rolling daily log files. Attach the most recent file when + reporting an issue. +
+ {path &&
{path}
} +
+ +
+ {error && ( +
+ {error} +
+ )} +
+ ); +}; + const DeveloperOptionsPanel = () => { const { navigateToSettings, navigateBack, breadcrumbs } = useSettingsNavigation(); const showSentryTest = APP_ENVIRONMENT === 'staging'; @@ -263,6 +319,7 @@ const DeveloperOptionsPanel = () => { />
+ {showSentryTest && } {developerItems.map((item, index) => ( ({ trigger: vi.fn(), appEnvironment: 'staging' as 'staging' | 'production' | 'development', + invoke: vi.fn(), + isTauri: vi.fn(() => true), })); +vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invoke, isTauri: hoisted.isTauri })); + vi.mock('../../../../services/analytics', () => ({ triggerSentryTestEvent: hoisted.trigger })); vi.mock('../../../../utils/config', () => ({ @@ -38,6 +42,14 @@ async function importPanel() { describe('DeveloperOptionsPanel — Sentry test row', () => { beforeEach(() => { hoisted.trigger.mockReset(); + // The panel always renders LogsFolderRow, which fires + // `invoke('logs_folder_path')` on mount. Stub it to a resolved no-op + // so this suite's tests focus on the Sentry row without unhandled + // rejections from the App-logs effect. + hoisted.invoke.mockReset(); + hoisted.invoke.mockResolvedValue(null); + hoisted.isTauri.mockReset(); + hoisted.isTauri.mockReturnValue(true); hoisted.appEnvironment = 'staging'; }); @@ -104,3 +116,89 @@ describe('DeveloperOptionsPanel — Sentry test row', () => { }); }); }); + +describe('DeveloperOptionsPanel — App logs row', () => { + beforeEach(() => { + hoisted.invoke.mockReset(); + hoisted.isTauri.mockReset(); + hoisted.isTauri.mockReturnValue(true); + // Force production so the staging Sentry row stays hidden and we + // assert against the App logs row in isolation. + hoisted.appEnvironment = 'production'; + }); + + test('renders nothing when not running under Tauri', async () => { + hoisted.isTauri.mockReturnValue(false); + vi.resetModules(); + const Panel = await importPanel(); + renderWithProviders(); + expect(screen.queryByText(/App logs/i)).toBeNull(); + expect(screen.queryByRole('button', { name: /Open logs folder/i })).toBeNull(); + }); + + test('shows the resolved log path on mount', async () => { + hoisted.invoke.mockImplementation((cmd: string) => { + if (cmd === 'logs_folder_path') return Promise.resolve('/tmp/openhuman/logs'); + return Promise.resolve(); + }); + vi.resetModules(); + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('/tmp/openhuman/logs')).toBeInTheDocument(); + }); + expect(hoisted.invoke).toHaveBeenCalledWith('logs_folder_path'); + }); + + test('invokes reveal_logs_folder on click', async () => { + hoisted.invoke.mockImplementation((cmd: string) => { + if (cmd === 'logs_folder_path') return Promise.resolve(null); + if (cmd === 'reveal_logs_folder') return Promise.resolve(); + return Promise.resolve(); + }); + vi.resetModules(); + const Panel = await importPanel(); + renderWithProviders(); + + fireEvent.click(screen.getByRole('button', { name: /Open logs folder/i })); + + await waitFor(() => { + expect(hoisted.invoke).toHaveBeenCalledWith('reveal_logs_folder'); + }); + }); + + test('surfaces the reveal error message in the live region', async () => { + hoisted.invoke.mockImplementation((cmd: string) => { + if (cmd === 'logs_folder_path') return Promise.resolve(null); + if (cmd === 'reveal_logs_folder') + return Promise.reject(new Error('log directory not initialized')); + return Promise.resolve(); + }); + vi.resetModules(); + const Panel = await importPanel(); + renderWithProviders(); + + fireEvent.click(screen.getByRole('button', { name: /Open logs folder/i })); + + await waitFor(() => { + expect(screen.getByText(/log directory not initialized/i)).toBeInTheDocument(); + }); + const live = screen.getByRole('status'); + expect(live).toHaveAttribute('aria-live', 'polite'); + }); + + test('surfaces the path-resolve error when logs_folder_path rejects', async () => { + hoisted.invoke.mockImplementation((cmd: string) => { + if (cmd === 'logs_folder_path') return Promise.reject(new Error('boom')); + return Promise.resolve(); + }); + vi.resetModules(); + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText(/boom/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/core/logging.rs b/src/core/logging.rs index f0eb1cf58..f0af1166b 100644 --- a/src/core/logging.rs +++ b/src/core/logging.rs @@ -1,13 +1,24 @@ //! Logging for `openhuman run` (and other CLI paths that need stderr output). //! //! Without initializing a subscriber, `log::` and `tracing::` macros are no-ops. +//! +//! Two entry points share the same formatter and `EnvFilter`: +//! * [`init_for_cli_run`] — stderr only, used by `openhuman run` / CLI +//! subcommands. +//! * [`init_for_embedded`] — stderr + a daily-rotated file under +//! `/logs/openhuman-YYYY-MM-DD.log`, used by the Tauri shell +//! where stderr is invisible in packaged builds. Both shell `log::*` +//! calls and core `tracing::*` calls funnel into the same file via +//! [`tracing_log::LogTracer`]. use std::fmt; use std::io::{self, IsTerminal}; -use std::sync::Once; +use std::path::{Path, PathBuf}; +use std::sync::{Once, OnceLock}; use nu_ansi_term::{Color, Style}; use tracing::{Event, Level}; +use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::fmt::format::{FormatEvent, FormatFields, Writer}; use tracing_subscriber::fmt::FmtContext; use tracing_subscriber::layer::SubscriberExt; @@ -17,6 +28,16 @@ use tracing_subscriber::Layer; static INIT: Once = Once::new(); +/// Holds the non-blocking writer guard for the file appender. Must live for +/// the entire process lifetime — dropping it stops the background flushing +/// thread and silently swallows pending log records. +static FILE_GUARD: OnceLock = OnceLock::new(); + +/// Resolved path to the active log file directory. Populated by +/// [`init_for_embedded`] so UI commands (e.g. `reveal_logs_folder`) can find +/// it without re-deriving the data dir. +static LOG_DIR: OnceLock = OnceLock::new(); + /// Default `RUST_LOG` when it is unset: either global levels or only the inline autocomplete module tree. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CliLogDefault { @@ -140,38 +161,8 @@ fn event_matches_file_constraints(meta: &tracing::Metadata<'_>, constraints: &[S /// It is idempotent and will only initialize the subscriber once per process. pub fn init_for_cli_run(verbose: bool, default_scope: CliLogDefault) { INIT.call_once(|| { - // Set RUST_LOG environment variable if not already set by the user. - if std::env::var_os("RUST_LOG").is_none() { - let default = match default_scope { - CliLogDefault::Global => { - if verbose { - "debug".to_string() - } else { - "info".to_string() - } - } - CliLogDefault::AutocompleteOnly => { - let level = if verbose { "trace" } else { "debug" }; - format!("off,openhuman_core::openhuman::autocomplete={level}") - } - }; - std::env::set_var("RUST_LOG", default); - } - - // Try parsing the EnvFilter from environment or use defaults. - let filter = tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { - match default_scope { - CliLogDefault::Global => { - tracing_subscriber::EnvFilter::new(if verbose { "debug" } else { "info" }) - } - CliLogDefault::AutocompleteOnly => { - let level = if verbose { "trace" } else { "debug" }; - tracing_subscriber::EnvFilter::new(format!( - "off,openhuman_core::openhuman::autocomplete={level}" - )) - } - } - }); + seed_rust_log(verbose, default_scope); + let filter = build_env_filter(verbose, default_scope); // Color resolution logic. let use_color = if std::env::var_os("NO_COLOR").is_some() { @@ -184,36 +175,310 @@ pub fn init_for_cli_run(verbose: bool, default_scope: CliLogDefault) { // Auto-detect based on stderr terminal status. io::stderr().is_terminal() }; - let file_constraints = parse_log_file_constraints(); + let cli_constraints = parse_log_file_constraints(); // Build the primary formatting layer. let fmt_layer = tracing_subscriber::fmt::layer() .with_ansi(use_color) .event_format(CleanCliFormat) .with_filter(tracing_subscriber::filter::filter_fn(move |meta| { - event_matches_file_constraints(meta, &file_constraints) + event_matches_file_constraints(meta, &cli_constraints) })); - // Build the Sentry integration layer. - let sentry_layer = - sentry::integrations::tracing::layer().event_filter(|md: &tracing::Metadata<'_>| { - match *md.level() { - Level::ERROR => sentry::integrations::tracing::EventFilter::Event, - Level::WARN | Level::INFO => { - sentry::integrations::tracing::EventFilter::Breadcrumb - } - _ => sentry::integrations::tracing::EventFilter::Ignore, - } - }); - // Register the subscriber with all layers. let _ = tracing_subscriber::registry() .with(filter) .with(fmt_layer) - .with(sentry_layer) + .with(sentry_tracing_layer()) .try_init(); // Bridge the `log` crate. let _ = tracing_log::LogTracer::init(); }); } + +/// Initialize logging for the embedded core running inside the Tauri shell. +/// +/// Installs: +/// * a stderr layer (for `tauri dev` / terminal launches), with ANSI when +/// attached to a TTY, +/// * a non-blocking, daily-rotated file appender at +/// `/logs/openhuman-YYYY-MM-DD.log` so packaged GUI builds — +/// where stderr is invisible — still produce a log users can share for +/// support, +/// * the Sentry breadcrumb/event layer, +/// * the `tracing_log::LogTracer` bridge so the Tauri shell's `log::*` +/// calls (currently routed through `env_logger`) flow into the same +/// file alongside core `tracing::*` events. +/// +/// Idempotent (`Once`-guarded). Safe to call from `run()` multiple times +/// across re-execs; subsequent calls are no-ops. The first caller wins, so +/// the Tauri shell should call this before any CLI path could initialize a +/// stderr-only subscriber. +pub fn init_for_embedded(data_dir: &Path, verbose: bool) { + INIT.call_once(|| { + let scope = CliLogDefault::Global; + seed_rust_log(verbose, scope); + let filter = build_env_filter(verbose, scope); + + let logs_dir = data_dir.join("logs"); + // Build the file appender first, but keep the writer guard + path in + // locals — only commit to `FILE_GUARD` / `LOG_DIR` after `try_init()` + // succeeds. Otherwise a competing global subscriber would cause + // `try_init` to return Err and `log_directory()` would still report a + // path even though no file layer is attached. Errors are surfaced via + // `eprintln!` (the tracing subscriber isn't installed yet here) using + // the same `[logging]` prefix as the dir-creation diagnostic. + let pending_file: Option<(_, tracing_appender::non_blocking::WorkerGuard, PathBuf)> = + match std::fs::create_dir_all(&logs_dir) { + Ok(()) => match tracing_appender::rolling::Builder::new() + .rotation(tracing_appender::rolling::Rotation::DAILY) + .filename_prefix("openhuman") + .filename_suffix("log") + .max_log_files(7) + .build(&logs_dir) + { + Ok(appender) => { + let (writer, guard) = tracing_appender::non_blocking(appender); + Some((writer, guard, logs_dir.clone())) + } + Err(err) => { + eprintln!( + "[logging] failed to create file appender in {}: {err}", + logs_dir.display() + ); + None + } + }, + Err(err) => { + eprintln!( + "[logging] failed to create logs dir {}: {err}", + logs_dir.display() + ); + None + } + }; + + let file_layer = pending_file.as_ref().map(|(writer, _, _)| { + let constraints = parse_log_file_constraints(); + tracing_subscriber::fmt::layer() + .with_ansi(false) + .event_format(CleanCliFormat) + .with_writer(writer.clone()) + .with_filter(tracing_subscriber::filter::filter_fn(move |meta| { + event_matches_file_constraints(meta, &constraints) + })) + }); + + // Stderr layer: useful for `tauri dev` and CLI-style launches. ANSI + // only when stderr is a real terminal. + let stderr_constraints = parse_log_file_constraints(); + let stderr_layer = tracing_subscriber::fmt::layer() + .with_ansi(io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none()) + .event_format(CleanCliFormat) + .with_filter(tracing_subscriber::filter::filter_fn(move |meta| { + event_matches_file_constraints(meta, &stderr_constraints) + })); + + match tracing_subscriber::registry() + .with(filter) + .with(stderr_layer) + .with(file_layer) + .with(sentry_tracing_layer()) + .try_init() + { + Ok(()) => { + if let Some((_, guard, dir)) = pending_file { + let _ = FILE_GUARD.set(guard); + let _ = LOG_DIR.set(dir); + } + } + Err(err) => { + // Another global subscriber was already installed (rare — + // typically a pre-existing CLI init in the same process). + // Drop the writer guard so the background flushing thread + // shuts down cleanly, and leave LOG_DIR unset so the UI + // surfaces "logging not initialized" instead of pointing at + // an empty directory. + eprintln!("[logging] tracing subscriber init failed: {err}"); + } + } + + let _ = tracing_log::LogTracer::init(); + }); +} + +/// Path to the active log directory (set by [`init_for_embedded`]). Returns +/// `None` if logging hasn't been initialized in embedded mode (e.g. bare +/// CLI runs). +pub fn log_directory() -> Option<&'static Path> { + LOG_DIR.get().map(PathBuf::as_path) +} + +fn seed_rust_log(verbose: bool, default_scope: CliLogDefault) { + if std::env::var_os("RUST_LOG").is_some() { + return; + } + let default = match default_scope { + CliLogDefault::Global => { + if verbose { + "debug".to_string() + } else { + "info".to_string() + } + } + CliLogDefault::AutocompleteOnly => { + let level = if verbose { "trace" } else { "debug" }; + format!("off,openhuman_core::openhuman::autocomplete={level}") + } + }; + std::env::set_var("RUST_LOG", default); +} + +fn build_env_filter(verbose: bool, default_scope: CliLogDefault) -> tracing_subscriber::EnvFilter { + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| match default_scope { + CliLogDefault::Global => { + tracing_subscriber::EnvFilter::new(if verbose { "debug" } else { "info" }) + } + CliLogDefault::AutocompleteOnly => { + let level = if verbose { "trace" } else { "debug" }; + tracing_subscriber::EnvFilter::new(format!( + "off,openhuman_core::openhuman::autocomplete={level}" + )) + } + }) +} + +fn sentry_tracing_layer() -> impl Layer +where + S: tracing::Subscriber + for<'a> LookupSpan<'a>, +{ + sentry::integrations::tracing::layer().event_filter(|md: &tracing::Metadata<'_>| { + match *md.level() { + Level::ERROR => sentry::integrations::tracing::EventFilter::Event, + Level::WARN | Level::INFO => sentry::integrations::tracing::EventFilter::Breadcrumb, + _ => sentry::integrations::tracing::EventFilter::Ignore, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Serialize tests that mutate `RUST_LOG` / `OPENHUMAN_LOG_FILE_CONSTRAINTS` — + /// Cargo runs unit tests in parallel threads in the same process, so + /// concurrent env-var writes would race. + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn with_clean_rust_log(f: impl FnOnce() -> R) -> R { + let _guard = ENV_LOCK.lock().unwrap(); + let prior = std::env::var("RUST_LOG").ok(); + std::env::remove_var("RUST_LOG"); + let result = f(); + match prior { + Some(v) => std::env::set_var("RUST_LOG", v), + None => std::env::remove_var("RUST_LOG"), + } + result + } + + #[test] + fn level_tag_covers_all_levels() { + assert_eq!(level_tag(&Level::ERROR), "ERR"); + assert_eq!(level_tag(&Level::WARN), "WRN"); + assert_eq!(level_tag(&Level::INFO), "INF"); + assert_eq!(level_tag(&Level::DEBUG), "DBG"); + assert_eq!(level_tag(&Level::TRACE), "TRC"); + } + + #[test] + fn short_target_strips_module_path() { + assert_eq!(short_target("openhuman_core::core::rpc"), "rpc"); + // Non-namespaced target stays as-is. + assert_eq!(short_target("plain"), "plain"); + } + + #[test] + fn seed_rust_log_global_uses_info_by_default() { + with_clean_rust_log(|| { + seed_rust_log(false, CliLogDefault::Global); + assert_eq!(std::env::var("RUST_LOG").unwrap(), "info"); + }); + } + + #[test] + fn seed_rust_log_global_uses_debug_when_verbose() { + with_clean_rust_log(|| { + seed_rust_log(true, CliLogDefault::Global); + assert_eq!(std::env::var("RUST_LOG").unwrap(), "debug"); + }); + } + + #[test] + fn seed_rust_log_autocomplete_scopes_to_module() { + with_clean_rust_log(|| { + seed_rust_log(false, CliLogDefault::AutocompleteOnly); + assert_eq!( + std::env::var("RUST_LOG").unwrap(), + "off,openhuman_core::openhuman::autocomplete=debug" + ); + }); + with_clean_rust_log(|| { + seed_rust_log(true, CliLogDefault::AutocompleteOnly); + assert_eq!( + std::env::var("RUST_LOG").unwrap(), + "off,openhuman_core::openhuman::autocomplete=trace" + ); + }); + } + + #[test] + fn seed_rust_log_respects_existing_value() { + let _guard = ENV_LOCK.lock().unwrap(); + let prior = std::env::var("RUST_LOG").ok(); + std::env::set_var("RUST_LOG", "warn"); + seed_rust_log(true, CliLogDefault::Global); + // Caller's existing setting must not be clobbered. + assert_eq!(std::env::var("RUST_LOG").unwrap(), "warn"); + match prior { + Some(v) => std::env::set_var("RUST_LOG", v), + None => std::env::remove_var("RUST_LOG"), + } + } + + #[test] + fn build_env_filter_returns_a_filter() { + // Smoke test: shouldn't panic and should produce *some* filter regardless of inputs. + let _ = build_env_filter(false, CliLogDefault::Global); + let _ = build_env_filter(true, CliLogDefault::AutocompleteOnly); + } + + #[test] + fn parse_log_file_constraints_handles_csv_and_whitespace() { + let _guard = ENV_LOCK.lock().unwrap(); + let prior = std::env::var("OPENHUMAN_LOG_FILE_CONSTRAINTS").ok(); + std::env::set_var("OPENHUMAN_LOG_FILE_CONSTRAINTS", "rpc, , agent ,memory"); + let parsed = parse_log_file_constraints(); + assert_eq!(parsed, vec!["rpc", "agent", "memory"]); + + std::env::remove_var("OPENHUMAN_LOG_FILE_CONSTRAINTS"); + assert!(parse_log_file_constraints().is_empty()); + + match prior { + Some(v) => std::env::set_var("OPENHUMAN_LOG_FILE_CONSTRAINTS", v), + None => std::env::remove_var("OPENHUMAN_LOG_FILE_CONSTRAINTS"), + } + } + + #[test] + fn log_directory_is_none_before_init_for_embedded() { + // In a fresh `cargo test` process where no test has called + // `init_for_embedded`, `log_directory()` must return `None` so the + // shell-side `reveal_logs_folder` command can surface a clear + // error rather than launching against an empty path. + if LOG_DIR.get().is_none() { + assert!(log_directory().is_none()); + } + } +}