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
@@ -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();
});
});
});