From f21c997b178adf56be88edc722287e8621ff8fd6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 6 Jul 2026 17:50:45 +0200 Subject: [PATCH] Include desktop app logs in diagnostics (#1914) * fix(diagnostics): include desktop app logs Settings diagnostics now collect the Electron main-process log separately from daemon.log so desktop startup and environment-probe failures are included in app reports. * refactor(desktop): share log tail reader * fix(diagnostics): surface desktop app log read errors * refactor(diagnostics): tighten desktop report API --- .../src/components/app-diagnostic-sheet.tsx | 39 +------ .../app/src/desktop/daemon/desktop-daemon.ts | 16 +++ .../diagnostics/app-diagnostic-report.test.ts | 1 + .../desktop-diagnostic-report.test.ts | 93 +++++++++++++++ .../diagnostics/desktop-diagnostic-report.ts | 106 ++++++++++++++++++ .../desktop/src/daemon/daemon-manager.test.ts | 31 ++++- packages/desktop/src/daemon/daemon-manager.ts | 12 +- packages/desktop/src/diagnostics/app-logs.ts | 17 +++ .../desktop/src/diagnostics/tail-file.test.ts | 34 ++++++ packages/desktop/src/diagnostics/tail-file.ts | 21 ++++ 10 files changed, 322 insertions(+), 48 deletions(-) create mode 100644 packages/app/src/diagnostics/desktop-diagnostic-report.test.ts create mode 100644 packages/app/src/diagnostics/desktop-diagnostic-report.ts create mode 100644 packages/desktop/src/diagnostics/app-logs.ts create mode 100644 packages/desktop/src/diagnostics/tail-file.test.ts create mode 100644 packages/desktop/src/diagnostics/tail-file.ts diff --git a/packages/app/src/components/app-diagnostic-sheet.tsx b/packages/app/src/components/app-diagnostic-sheet.tsx index c82d7897c..72e9d990c 100644 --- a/packages/app/src/components/app-diagnostic-sheet.tsx +++ b/packages/app/src/components/app-diagnostic-sheet.tsx @@ -9,7 +9,6 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface"; import { useToast } from "@/contexts/toast-context"; -import { getDesktopDaemonLogs, getDesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon"; import { formatAppDiagnosticHeader, formatDiagnosticSection, @@ -17,6 +16,7 @@ import { formatServerInfoSection, redactAppDiagnosticReport, } from "@/diagnostics/app-diagnostic-report"; +import { collectDesktopDiagnosticSections } from "@/diagnostics/desktop-diagnostic-report"; import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { HostProfile } from "@/types/host-connection"; @@ -261,35 +261,6 @@ export function AppDiagnosticSheet({ ); } -async function collectDesktopDiagnosticSections(): Promise { - try { - const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]); - return { - status: "done", - sections: [ - formatDiagnosticSection("Desktop", [ - { label: "Daemon status", value: status.status }, - { label: "Desktop managed", value: String(status.desktopManaged) }, - { label: "Daemon PID", value: status.pid === null ? "none" : String(status.pid) }, - { label: "Daemon version", value: status.version ?? "unknown" }, - { label: "Daemon home", value: status.home || "unknown" }, - { label: "Log path", value: logs.logPath || "unknown" }, - { label: "Error", value: status.error ?? "none" }, - ]), - [ - "Desktop daemon log tail", - logs.contents ? indentBlock(logs.contents) : " No log lines found", - ].join("\n"), - ], - }; - } catch (error) { - return { - status: "failed", - sections: [formatDiagnosticSection("Desktop", [{ label: "Error", value: toMessage(error) }])], - }; - } -} - async function collectHostDiagnosticSections( host: HostProfile, snapshot: HostRuntimeSnapshot | null, @@ -338,14 +309,6 @@ async function collectHostDiagnosticSections( } } -function indentBlock(value: string): string { - return value - .split("\n") - .filter(Boolean) - .map((line) => ` ${line}`) - .join("\n"); -} - function toMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index 3c8e0a7ae..e2729f2cb 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -28,6 +28,11 @@ export interface DesktopDaemonLogs { contents: string; } +export interface DesktopAppLogs { + logPath: string; + contents: string; +} + export interface DesktopPairingOffer { relayEnabled: boolean; url: string | null; @@ -144,6 +149,17 @@ export async function getDesktopDaemonLogs(): Promise { return parseDesktopDaemonLogs(await invokeDesktopCommand("desktop_daemon_logs")); } +export async function getDesktopAppLogs(): Promise { + const raw = await invokeDesktopCommand("desktop_app_logs"); + if (!isRecord(raw)) { + throw new Error("Unexpected desktop app logs response."); + } + return { + logPath: toStringOrNull(raw.logPath) ?? "", + contents: typeof raw.contents === "string" ? raw.contents : "", + }; +} + export async function getDesktopDaemonPairing(): Promise { return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing")); } diff --git a/packages/app/src/diagnostics/app-diagnostic-report.test.ts b/packages/app/src/diagnostics/app-diagnostic-report.test.ts index 1f437ce81..44512e3ee 100644 --- a/packages/app/src/diagnostics/app-diagnostic-report.test.ts +++ b/packages/app/src/diagnostics/app-diagnostic-report.test.ts @@ -82,6 +82,7 @@ describe("app diagnostics report", () => { const host = makeHost(); const redacted = redactAppDiagnosticReport( [ + "Desktop app log tail", "secret.example.test:6767", "relay.secret.test:443", "daemon-public-key-secret", diff --git a/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts b/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts new file mode 100644 index 000000000..e26e1e776 --- /dev/null +++ b/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "vitest"; +import { + collectDesktopDiagnosticSections, + type DesktopDiagnosticSources, +} from "./desktop-diagnostic-report"; + +function makeSources(): DesktopDiagnosticSources { + return { + getStatus: async () => ({ + serverId: "server-1", + status: "running", + listen: "127.0.0.1:6767", + hostname: "host", + pid: 4242, + home: "/paseo/home", + version: "1.2.3", + desktopManaged: true, + error: null, + }), + getDaemonLogs: async () => ({ + logPath: "/paseo/home/daemon.log", + contents: "daemon line one\ndaemon line two", + }), + getAppLogs: async () => ({ + logPath: "/logs/Paseo/main.log", + contents: "[login-shell-env] start\n[login-shell-env] failed", + }), + }; +} + +describe("desktop diagnostic report", () => { + test("starts desktop diagnostic requests together", async () => { + const calls: string[] = []; + let releaseAppLogs: () => void = () => {}; + const appLogGate = new Promise((resolve) => { + releaseAppLogs = resolve; + }); + const sources: DesktopDiagnosticSources = { + ...makeSources(), + getStatus: async () => { + calls.push("status"); + return makeSources().getStatus(); + }, + getDaemonLogs: async () => { + calls.push("daemonLogs"); + return makeSources().getDaemonLogs(); + }, + getAppLogs: async () => { + calls.push("appLogs"); + await appLogGate; + return makeSources().getAppLogs(); + }, + }; + + const resultPromise = collectDesktopDiagnosticSections(sources); + + expect(calls).toEqual(["status", "daemonLogs", "appLogs"]); + releaseAppLogs(); + await expect(resultPromise).resolves.toMatchObject({ status: "done" }); + }); + + test("includes the Electron main-process log after the daemon log", async () => { + const result = await collectDesktopDiagnosticSections(makeSources()); + const report = result.sections.join("\n\n"); + + expect(result.status).toBe("done"); + expect(report).toContain(" Log path: /paseo/home/daemon.log"); + expect(report).toContain(" App log path: /logs/Paseo/main.log"); + expect(report).toContain("Desktop daemon log tail\n daemon line one\n daemon line two"); + expect(report).toContain( + "Desktop app log tail\n [login-shell-env] start\n [login-shell-env] failed", + ); + expect(report.indexOf("Desktop app log tail")).toBeGreaterThan( + report.indexOf("Desktop daemon log tail"), + ); + }); + + test("keeps daemon diagnostics when the Electron app log fails", async () => { + const sources = { + ...makeSources(), + getAppLogs: async () => { + throw new Error("app log unavailable"); + }, + }; + + const result = await collectDesktopDiagnosticSections(sources); + const report = result.sections.join("\n\n"); + + expect(result.status).toBe("failed"); + expect(report).toContain("Desktop daemon log tail\n daemon line one\n daemon line two"); + expect(report).toContain("Desktop app log tail\n Error: app log unavailable"); + }); +}); diff --git a/packages/app/src/diagnostics/desktop-diagnostic-report.ts b/packages/app/src/diagnostics/desktop-diagnostic-report.ts new file mode 100644 index 000000000..8fd26c674 --- /dev/null +++ b/packages/app/src/diagnostics/desktop-diagnostic-report.ts @@ -0,0 +1,106 @@ +import { + getDesktopAppLogs, + getDesktopDaemonLogs, + getDesktopDaemonStatus, + type DesktopAppLogs, + type DesktopDaemonLogs, + type DesktopDaemonStatus, +} from "@/desktop/daemon/desktop-daemon"; +import { formatDiagnosticSection } from "./app-diagnostic-report"; + +type DesktopDiagnosticStatus = "done" | "failed"; + +export interface DesktopDiagnosticCollectionResult { + sections: string[]; + status: DesktopDiagnosticStatus; +} + +export interface DesktopDiagnosticSources { + getStatus: () => Promise; + getDaemonLogs: () => Promise; + getAppLogs: () => Promise; +} + +const DEFAULT_DESKTOP_DIAGNOSTIC_SOURCES: DesktopDiagnosticSources = { + getStatus: getDesktopDaemonStatus, + getDaemonLogs: getDesktopDaemonLogs, + getAppLogs: getDesktopAppLogs, +}; + +export async function collectDesktopDiagnosticSections( + sources: DesktopDiagnosticSources = DEFAULT_DESKTOP_DIAGNOSTIC_SOURCES, +): Promise { + const sections: string[] = []; + let failed = false; + + const [daemonResult, appLogsResult] = await Promise.allSettled([ + Promise.all([sources.getStatus(), sources.getDaemonLogs()]), + sources.getAppLogs(), + ]); + + if (daemonResult.status === "fulfilled") { + const [status, daemonLogs] = daemonResult.value; + const appLogs = appLogsResult.status === "fulfilled" ? appLogsResult.value : null; + sections.unshift(...formatDesktopDaemonSections({ status, daemonLogs, appLogs })); + } else { + failed = true; + sections.unshift( + formatDiagnosticSection("Desktop", [ + { label: "Error", value: toMessage(daemonResult.reason) }, + ]), + ); + } + + if (appLogsResult.status === "fulfilled") { + sections.push(formatLogTailSection("Desktop app log tail", appLogsResult.value.contents)); + } else { + failed = true; + sections.push( + formatDiagnosticSection("Desktop app log tail", [ + { label: "Error", value: toMessage(appLogsResult.reason) }, + ]), + ); + } + + return { + status: failed ? "failed" : "done", + sections, + }; +} + +function formatDesktopDaemonSections(input: { + status: DesktopDaemonStatus; + daemonLogs: DesktopDaemonLogs; + appLogs: DesktopAppLogs | null; +}): string[] { + const { status, daemonLogs, appLogs } = input; + return [ + formatDiagnosticSection("Desktop", [ + { label: "Daemon status", value: status.status }, + { label: "Desktop managed", value: String(status.desktopManaged) }, + { label: "Daemon PID", value: status.pid === null ? "none" : String(status.pid) }, + { label: "Daemon version", value: status.version ?? "unknown" }, + { label: "Daemon home", value: status.home || "unknown" }, + { label: "Log path", value: daemonLogs.logPath || "unknown" }, + { label: "App log path", value: appLogs?.logPath || "unavailable" }, + { label: "Error", value: status.error ?? "none" }, + ]), + formatLogTailSection("Desktop daemon log tail", daemonLogs.contents), + ]; +} + +function formatLogTailSection(title: string, contents: string): string { + return [title, contents ? indentBlock(contents) : " No log lines found"].join("\n"); +} + +function indentBlock(value: string): string { + return value + .split("\n") + .filter(Boolean) + .map((line) => ` ${line}`) + .join("\n"); +} + +function toMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/desktop/src/daemon/daemon-manager.test.ts b/packages/desktop/src/daemon/daemon-manager.test.ts index 695fbf72a..c5bc67926 100644 --- a/packages/desktop/src/daemon/daemon-manager.test.ts +++ b/packages/desktop/src/daemon/daemon-manager.test.ts @@ -19,6 +19,8 @@ const mocks = vi.hoisted(() => ({ spawnProcess: vi.fn(), logInfo: vi.fn(), logError: vi.fn(), + appLogPath: "/tmp/paseo-desktop-daemon-manager-test-main.log", + getElectronLogFile: vi.fn(), })); vi.mock("electron", () => ({ @@ -32,7 +34,15 @@ vi.mock("electron", () => ({ })); vi.mock("electron-log/main", () => ({ - default: { info: mocks.logInfo, error: mocks.logError }, + default: { + info: mocks.logInfo, + error: mocks.logError, + transports: { + file: { + getFile: mocks.getElectronLogFile, + }, + }, + }, })); vi.mock("@getpaseo/server", () => ({ @@ -105,11 +115,15 @@ describe("daemon-manager commands", () => { mocks.spawnProcess.mockReset(); mocks.logInfo.mockReset(); mocks.logError.mockReset(); + mocks.getElectronLogFile.mockReset(); + mocks.getElectronLogFile.mockReturnValue({ path: mocks.appLogPath }); rmSync(mocks.paseoHome, { recursive: true, force: true }); + rmSync(mocks.appLogPath, { force: true }); }); afterEach(() => { rmSync(mocks.paseoHome, { recursive: true, force: true }); + rmSync(mocks.appLogPath, { force: true }); }); it("refuses start and restart while built-in daemon management is disabled", async () => { @@ -435,4 +449,19 @@ describe("daemon-manager commands", () => { }), ); }); + + it("returns the Electron main-process log tail from electron-log", () => { + writeFileSync( + mocks.appLogPath, + Array.from({ length: 105 }, (_value, index) => `main log line ${index + 1}`).join("\n"), + ); + const handlers = createDaemonCommandHandlers(); + + expect(handlers.desktop_app_logs()).toEqual({ + logPath: mocks.appLogPath, + contents: Array.from({ length: 100 }, (_value, index) => `main log line ${index + 6}`).join( + "\n", + ), + }); + }); }); diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 10bae9a95..331454e45 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -39,6 +39,8 @@ import { import type { DesktopSettings } from "../settings/desktop-settings.js"; import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js"; import { isRunningUnderARM64Translation } from "../system/arm64-translation.js"; +import { getDesktopAppLogs } from "../diagnostics/app-logs.js"; +import { tailFile } from "../diagnostics/tail-file.js"; const DAEMON_LOG_FILENAME = "daemon.log"; const STARTUP_POLL_INTERVAL_MS = 200; @@ -211,15 +213,6 @@ function sleep(ms: number): Promise { }); } -function tailFile(filePath: string, lines = 50): string { - try { - const content = readFileSync(filePath, "utf-8"); - return content.split("\n").filter(Boolean).slice(-lines).join("\n"); - } catch { - return ""; - } -} - function logDesktopDaemonLifecycle(message: string, details?: Record): void { log.info("[desktop daemon]", message, { pid: process.pid, @@ -574,6 +567,7 @@ export function createDaemonCommandHandlers(): Record stopDesktopDaemon(parseDesktopDaemonStopReason(args)), restart_desktop_daemon: () => restartDaemon(), desktop_daemon_logs: () => getDaemonLogs(), + desktop_app_logs: () => getDesktopAppLogs(), desktop_daemon_pairing: () => getDaemonPairing(), desktop_get_system_idle_time: () => powerMonitor.getSystemIdleTime() * 1000, cli_daemon_status: () => getCliDaemonStatus(), diff --git a/packages/desktop/src/diagnostics/app-logs.ts b/packages/desktop/src/diagnostics/app-logs.ts new file mode 100644 index 000000000..8bbb4a59f --- /dev/null +++ b/packages/desktop/src/diagnostics/app-logs.ts @@ -0,0 +1,17 @@ +import log from "electron-log/main"; +import { tailFile } from "./tail-file.js"; + +const APP_LOG_TAIL_LINES = 100; + +export interface DesktopAppLogs { + logPath: string; + contents: string; +} + +export function getDesktopAppLogs(): DesktopAppLogs { + const logPath = log.transports.file.getFile().path; + return { + logPath, + contents: tailFile(logPath, APP_LOG_TAIL_LINES, { throwOnReadError: true }), + }; +} diff --git a/packages/desktop/src/diagnostics/tail-file.test.ts b/packages/desktop/src/diagnostics/tail-file.test.ts new file mode 100644 index 000000000..e2e2362a1 --- /dev/null +++ b/packages/desktop/src/diagnostics/tail-file.test.ts @@ -0,0 +1,34 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { tailFile } from "./tail-file"; + +let testDir = ""; + +describe("tailFile", () => { + beforeEach(() => { + testDir = mkdtempSync(path.join(tmpdir(), "paseo-tail-file-")); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it("returns the requested tail lines", () => { + const filePath = path.join(testDir, "main.log"); + writeFileSync(filePath, "one\ntwo\nthree\n"); + + expect(tailFile(filePath, 2)).toBe("two\nthree"); + }); + + it("keeps missing files empty", () => { + expect(tailFile(path.join(testDir, "missing.log"), 2)).toBe(""); + expect(tailFile(path.join(testDir, "missing.log"), 2, { throwOnReadError: true })).toBe(""); + }); + + it("can propagate read failures", () => { + expect(() => tailFile(testDir, 2, { throwOnReadError: true })).toThrow(); + expect(tailFile(testDir, 2)).toBe(""); + }); +}); diff --git a/packages/desktop/src/diagnostics/tail-file.ts b/packages/desktop/src/diagnostics/tail-file.ts new file mode 100644 index 000000000..8eda8d53b --- /dev/null +++ b/packages/desktop/src/diagnostics/tail-file.ts @@ -0,0 +1,21 @@ +import { readFileSync } from "node:fs"; + +interface TailFileOptions { + throwOnReadError?: boolean; +} + +export function tailFile(filePath: string, lines = 50, options: TailFileOptions = {}): string { + try { + const content = readFileSync(filePath, "utf-8"); + return content.split("\n").filter(Boolean).slice(-lines).join("\n"); + } catch (error) { + if (options.throwOnReadError && !isMissingFileError(error)) { + throw error; + } + return ""; + } +} + +function isMissingFileError(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; +}