feat(logging): rotate embedded core + shell logs to ~/.openhuman/logs (#1278)

This commit is contained in:
Steven Enamakel
2026-05-06 13:53:45 -07:00
committed by GitHub
parent f449a30520
commit abec9921c4
9 changed files with 677 additions and 55 deletions
Generated
+30 -1
View File
@@ -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]]
+1
View File
@@ -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"
+20
View File
@@ -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"
@@ -62,5 +62,12 @@ allow = [
"gmail_trash",
"gmail_add_label",
"gmail_find_linkedin_profile_url",
# Surface the embedded core's daily-rotated log directory
# (`<data_dir>/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 = []
+139
View File
@@ -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 `<data_dir>/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 `<temp>/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<String> = 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<String> {
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()))
}
+11 -5
View File
@@ -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
// `<data_dir>/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")
@@ -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<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isTauri()) return;
invoke<string | null>('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 (
<div className="px-4 py-3 mb-3 rounded-lg border border-slate-200 bg-slate-50">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-slate-900">App logs</div>
<div className="text-xs text-slate-700 mt-0.5">
Open the folder containing rolling daily log files. Attach the most recent file when
reporting an issue.
</div>
{path && <div className="text-[11px] text-slate-500 mt-1 font-mono truncate">{path}</div>}
</div>
<button
onClick={onClick}
className="shrink-0 px-3 py-1.5 rounded-md bg-slate-700 hover:bg-slate-600 text-white text-xs font-medium transition-colors">
Open logs folder
</button>
</div>
{error && (
<div role="status" aria-live="polite" className="mt-2 text-xs text-coral-600">
{error}
</div>
)}
</div>
);
};
const DeveloperOptionsPanel = () => {
const { navigateToSettings, navigateBack, breadcrumbs } = useSettingsNavigation();
const showSentryTest = APP_ENVIRONMENT === 'staging';
@@ -263,6 +319,7 @@ const DeveloperOptionsPanel = () => {
/>
<div>
<LogsFolderRow />
{showSentryTest && <SentryTestRow />}
{developerItems.map((item, index) => (
<SettingsMenuItem
@@ -12,8 +12,12 @@ import { renderWithProviders } from '../../../../test/test-utils';
const hoisted = vi.hoisted(() => ({
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(<Panel />);
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(<Panel />);
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(<Panel />);
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(<Panel />);
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(<Panel />);
await waitFor(() => {
expect(screen.getByText(/boom/i)).toBeInTheDocument();
});
});
});
+313 -48
View File
@@ -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
//! `<data_dir>/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<WorkerGuard> = 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<PathBuf> = 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
/// `<data_dir>/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<S>() -> impl Layer<S>
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<R>(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());
}
}
}