mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(logging): rotate embedded core + shell logs to ~/.openhuman/logs (#1278)
This commit is contained in:
Generated
+30
-1
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+20
@@ -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 = []
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user