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
This commit is contained in:
Mohamed Boudra
2026-07-06 17:50:45 +02:00
committed by GitHub
parent 7c1da0077b
commit f21c997b17
10 changed files with 322 additions and 48 deletions

View File

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

View File

@@ -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<DesktopDaemonLogs> {
return parseDesktopDaemonLogs(await invokeDesktopCommand("desktop_daemon_logs"));
}
export async function getDesktopAppLogs(): Promise<DesktopAppLogs> {
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<DesktopPairingOffer> {
return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing"));
}

View File

@@ -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",

View File

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

View File

@@ -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<DesktopDaemonStatus>;
getDaemonLogs: () => Promise<DesktopDaemonLogs>;
getAppLogs: () => Promise<DesktopAppLogs>;
}
const DEFAULT_DESKTOP_DIAGNOSTIC_SOURCES: DesktopDiagnosticSources = {
getStatus: getDesktopDaemonStatus,
getDaemonLogs: getDesktopDaemonLogs,
getAppLogs: getDesktopAppLogs,
};
export async function collectDesktopDiagnosticSections(
sources: DesktopDiagnosticSources = DEFAULT_DESKTOP_DIAGNOSTIC_SOURCES,
): Promise<DesktopDiagnosticCollectionResult> {
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);
}

View File

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

View File

@@ -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<void> {
});
}
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<string, unknown>): void {
log.info("[desktop daemon]", message, {
pid: process.pid,
@@ -574,6 +567,7 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
stop_desktop_daemon: (args) => 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(),

View File

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

View File

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

View File

@@ -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";
}