Skip to content

Commit abec992

Browse files
authored
feat(logging): rotate embedded core + shell logs to ~/.openhuman/logs (tinyhumansai#1278)
1 parent f449a30 commit abec992

9 files changed

Lines changed: 677 additions & 55 deletions

File tree

Cargo.lock

Lines changed: 30 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ schemars = "1.2"
7878
tracing = { version = "0.1", default-features = false }
7979
tracing-log = "0.2"
8080
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "ansi", "env-filter"] }
81+
tracing-appender = "0.2"
8182
prometheus = { version = "0.14", default-features = false }
8283
urlencoding = "2.1"
8384
thiserror = "2.0"

app/src-tauri/Cargo.lock

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/src-tauri/permissions/allow-core-process.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,12 @@ allow = [
6262
"gmail_trash",
6363
"gmail_add_label",
6464
"gmail_find_linkedin_profile_url",
65+
# Surface the embedded core's daily-rotated log directory
66+
# (`<data_dir>/logs/`) so the Settings → Developer Options panel can
67+
# show users the path and reveal it in the platform file manager when
68+
# collecting support bundles. Read-only; no writes occur in the
69+
# backing commands.
70+
"logs_folder_path",
71+
"reveal_logs_folder",
6572
]
6673
deny = []

app/src-tauri/src/file_logging.rs

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
//! Tauri shell side of file-based logging.
2+
//!
3+
//! Resolves the OpenHuman data directory the same way the core does
4+
//! (`~/.openhuman` or `OPENHUMAN_WORKSPACE` override) and hands it to
5+
//! [`openhuman_core::core::logging::init_for_embedded`], which installs a
6+
//! daily-rotated file appender so packaged GUI builds — where stderr is
7+
//! invisible — still produce a log users can share for support.
8+
//!
9+
//! Both the shell's `log::*` calls (via the `tracing_log::LogTracer` bridge)
10+
//! and the embedded core's `tracing::*` events funnel into the same file.
11+
12+
use std::path::PathBuf;
13+
14+
use openhuman_core::core::logging::{self, log_directory};
15+
16+
/// Initialize logging for the Tauri shell + embedded core. Idempotent and
17+
/// safe to call from any startup position; the underlying `Once` guard means
18+
/// the first caller's data dir wins.
19+
///
20+
/// Verbosity defaults to `info` (or `debug` when `OPENHUMAN_VERBOSE=1`); the
21+
/// `RUST_LOG` env var continues to override both.
22+
pub fn init() {
23+
let data_dir = resolve_data_dir();
24+
let verbose = std::env::var("OPENHUMAN_VERBOSE")
25+
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
26+
.unwrap_or(false);
27+
logging::init_for_embedded(&data_dir, verbose);
28+
}
29+
30+
/// Resolve the directory used to host `<data_dir>/logs/`. Mirrors the core's
31+
/// own resolution so log files sit next to `active_user.toml`, the per-user
32+
/// `users/` tree, and the CEF caches a support engineer would also need.
33+
///
34+
/// If `default_root_openhuman_dir` fails (very unusual — it requires
35+
/// `dirs::home_dir` to return `None`), falls back to `<temp>/openhuman`
36+
/// rather than a relative `.openhuman` whose final location depends on the
37+
/// shell's CWD at launch time.
38+
fn resolve_data_dir() -> PathBuf {
39+
if let Ok(workspace) = std::env::var("OPENHUMAN_WORKSPACE") {
40+
if !workspace.is_empty() {
41+
return PathBuf::from(workspace);
42+
}
43+
}
44+
openhuman_core::openhuman::config::default_root_openhuman_dir().unwrap_or_else(|err| {
45+
eprintln!(
46+
"[file_logging] default_root_openhuman_dir failed ({err}); falling back to temp dir"
47+
);
48+
std::env::temp_dir().join("openhuman")
49+
})
50+
}
51+
52+
#[cfg(test)]
53+
mod tests {
54+
use super::*;
55+
56+
/// Lock around env-var mutation. Cargo runs unit tests in parallel
57+
/// threads in the same process, so concurrent `set_var` / `remove_var`
58+
/// can race; the lock keeps the env stable for each test's duration.
59+
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
60+
61+
#[test]
62+
fn resolve_data_dir_honors_workspace_override() {
63+
let _guard = ENV_LOCK.lock().unwrap();
64+
let prior = std::env::var("OPENHUMAN_WORKSPACE").ok();
65+
std::env::set_var("OPENHUMAN_WORKSPACE", "/tmp/openhuman-test-override");
66+
let dir = resolve_data_dir();
67+
assert_eq!(dir, PathBuf::from("/tmp/openhuman-test-override"));
68+
match prior {
69+
Some(v) => std::env::set_var("OPENHUMAN_WORKSPACE", v),
70+
None => std::env::remove_var("OPENHUMAN_WORKSPACE"),
71+
}
72+
}
73+
74+
#[test]
75+
fn resolve_data_dir_ignores_empty_workspace() {
76+
let _guard = ENV_LOCK.lock().unwrap();
77+
let prior = std::env::var("OPENHUMAN_WORKSPACE").ok();
78+
std::env::set_var("OPENHUMAN_WORKSPACE", "");
79+
// Empty string must NOT short-circuit — fall through to the
80+
// default resolver so the user's real `~/.openhuman` is used.
81+
let dir = resolve_data_dir();
82+
assert_ne!(dir, PathBuf::from(""));
83+
assert!(dir.is_absolute(), "expected absolute fallback, got {dir:?}");
84+
match prior {
85+
Some(v) => std::env::set_var("OPENHUMAN_WORKSPACE", v),
86+
None => std::env::remove_var("OPENHUMAN_WORKSPACE"),
87+
}
88+
}
89+
90+
#[test]
91+
fn logs_folder_path_returns_none_pre_init() {
92+
// `init()` is `Once`-guarded across the whole process, so in unit
93+
// tests where the embedded subscriber hasn't been installed,
94+
// `logs_folder_path` should return `None` rather than a stale path.
95+
// (When run alongside a test that *did* call `init`, the function
96+
// is allowed to return Some — assert the type signature only.)
97+
let result = logs_folder_path();
98+
let _: Option<String> = result;
99+
}
100+
101+
#[test]
102+
fn reveal_logs_folder_errors_when_uninitialized() {
103+
// If logging hasn't been initialized, the command must surface a
104+
// typed error so the UI can show it instead of silently launching
105+
// an `open` against an empty path.
106+
if openhuman_core::core::logging::log_directory().is_none() {
107+
let err = reveal_logs_folder().expect_err("must error pre-init");
108+
assert!(err.contains("not initialized"), "unexpected error: {err}");
109+
}
110+
}
111+
}
112+
113+
/// Tauri command — return the absolute path to the active log directory, or
114+
/// `None` if logging hasn't been initialized in embedded mode (shouldn't
115+
/// happen at runtime; guard for tests).
116+
#[tauri::command]
117+
pub fn logs_folder_path() -> Option<String> {
118+
log_directory().map(|p| p.display().to_string())
119+
}
120+
121+
/// Tauri command — open the platform file manager at the log directory so a
122+
/// user can grab today's log file and send it to support.
123+
#[tauri::command]
124+
pub fn reveal_logs_folder() -> Result<(), String> {
125+
let dir = log_directory().ok_or_else(|| "log directory not initialized".to_string())?;
126+
127+
#[cfg(target_os = "macos")]
128+
let result = std::process::Command::new("open").arg(dir).spawn();
129+
130+
#[cfg(target_os = "windows")]
131+
let result = std::process::Command::new("explorer").arg(dir).spawn();
132+
133+
#[cfg(target_os = "linux")]
134+
let result = std::process::Command::new("xdg-open").arg(dir).spawn();
135+
136+
result
137+
.map(|_| ())
138+
.map_err(|e| format!("failed to open log directory {}: {e}", dir.display()))
139+
}

app/src-tauri/src/lib.rs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ mod core_process;
99
mod core_rpc;
1010
mod dictation_hotkeys;
1111
mod discord_scanner;
12+
mod file_logging;
1213
mod gmessages_scanner;
1314
mod imessage_scanner;
1415
#[cfg(target_os = "macos")]
@@ -1119,10 +1120,13 @@ pub fn run() {
11191120

11201121
let daemon_mode = is_daemon_mode();
11211122

1122-
let default_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string());
1123-
let _ = env_logger::Builder::new()
1124-
.parse_filters(&default_filter)
1125-
.try_init();
1123+
// Install the unified tracing subscriber + daily-rotated file appender
1124+
// before any other startup work so CEF preflight failures, sentry
1125+
// smoke-test events, and the rest of `run()` are captured in
1126+
// `<data_dir>/logs/openhuman-YYYY-MM-DD.log`. The shell's `log::*` calls
1127+
// are bridged into the same subscriber via `tracing_log::LogTracer`,
1128+
// replacing the previous stderr-only `env_logger`.
1129+
file_logging::init();
11261130

11271131
// The vendored tauri-cef dev-server proxy builds a reqwest 0.13 client
11281132
// (see vendor/tauri-cef/crates/tauri/src/protocol/tauri.rs) which calls
@@ -1655,7 +1659,9 @@ pub fn run() {
16551659
activate_main_window,
16561660
native_notifications::show_native_notification,
16571661
mascot_window_show,
1658-
mascot_window_hide
1662+
mascot_window_hide,
1663+
file_logging::reveal_logs_folder,
1664+
file_logging::logs_folder_path
16591665
])
16601666
.build(tauri::generate_context!())
16611667
.expect("error while building tauri application")

app/src/components/settings/panels/DeveloperOptionsPanel.tsx

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useState } from 'react';
1+
import { invoke, isTauri } from '@tauri-apps/api/core';
2+
import { useEffect, useState } from 'react';
23

34
import { triggerSentryTestEvent } from '../../../services/analytics';
45
import { APP_ENVIRONMENT } from '../../../utils/config';
@@ -249,6 +250,61 @@ const SentryTestRow = () => {
249250
);
250251
};
251252

253+
// Surfaces the on-disk log folder so users running into "stuck on
254+
// Initializing OpenHuman..." (and similar startup issues) can grab today's
255+
// `openhuman-YYYY-MM-DD.log` and send it to support without hunting through
256+
// `~/.openhuman/logs/`. Invokes the `reveal_logs_folder` Tauri command which
257+
// `open`/`explorer`/`xdg-open`s the directory in the platform file manager.
258+
const LogsFolderRow = () => {
259+
const [path, setPath] = useState<string | null>(null);
260+
const [error, setError] = useState<string | null>(null);
261+
262+
useEffect(() => {
263+
if (!isTauri()) return;
264+
invoke<string | null>('logs_folder_path')
265+
.then(p => setPath(p ?? null))
266+
.catch(err => {
267+
setError(err instanceof Error ? err.message : String(err));
268+
});
269+
}, []);
270+
271+
const onClick = async () => {
272+
setError(null);
273+
try {
274+
await invoke('reveal_logs_folder');
275+
} catch (err) {
276+
setError(err instanceof Error ? err.message : String(err));
277+
}
278+
};
279+
280+
if (!isTauri()) return null;
281+
282+
return (
283+
<div className="px-4 py-3 mb-3 rounded-lg border border-slate-200 bg-slate-50">
284+
<div className="flex items-center justify-between gap-3">
285+
<div className="min-w-0">
286+
<div className="text-sm font-semibold text-slate-900">App logs</div>
287+
<div className="text-xs text-slate-700 mt-0.5">
288+
Open the folder containing rolling daily log files. Attach the most recent file when
289+
reporting an issue.
290+
</div>
291+
{path && <div className="text-[11px] text-slate-500 mt-1 font-mono truncate">{path}</div>}
292+
</div>
293+
<button
294+
onClick={onClick}
295+
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">
296+
Open logs folder
297+
</button>
298+
</div>
299+
{error && (
300+
<div role="status" aria-live="polite" className="mt-2 text-xs text-coral-600">
301+
{error}
302+
</div>
303+
)}
304+
</div>
305+
);
306+
};
307+
252308
const DeveloperOptionsPanel = () => {
253309
const { navigateToSettings, navigateBack, breadcrumbs } = useSettingsNavigation();
254310
const showSentryTest = APP_ENVIRONMENT === 'staging';
@@ -263,6 +319,7 @@ const DeveloperOptionsPanel = () => {
263319
/>
264320

265321
<div>
322+
<LogsFolderRow />
266323
{showSentryTest && <SentryTestRow />}
267324
{developerItems.map((item, index) => (
268325
<SettingsMenuItem

0 commit comments

Comments
 (0)