Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions apps/desktop/main/desktop-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
RuntimeEventQueryResult,
RuntimeLogEntry,
RuntimeState,
StartupProbePayload,
} from "../shared/host";
import type { RuntimeOrchestrator } from "./runtime/daemon-supervisor";
import {
Expand Down Expand Up @@ -54,6 +55,17 @@ type DesktopDiagnosticsSnapshot = {
updatedAt: string;
isPackaged: boolean;
coldStart: DesktopColdStartSnapshot;
startupProbe: {
preloadSeen: boolean;
rendererSeen: boolean;
entries: Array<{
source: StartupProbePayload["source"];
stage: string;
status: StartupProbePayload["status"];
detail: string | null;
at: string;
}>;
};
sleepGuard: SleepGuardSnapshot;
renderer: DesktopRendererSnapshot;
embeddedContents: DesktopEmbeddedContentSnapshot[];
Expand All @@ -68,6 +80,8 @@ function nowIso(): string {
return new Date().toISOString();
}

const MAX_STARTUP_PROBE_ENTRIES = 200;

export function getDesktopDiagnosticsFilePath(): string {
return resolve(app.getPath("userData"), "logs", "desktop-diagnostics.json");
}
Expand All @@ -83,6 +97,12 @@ export class DesktopDiagnosticsReporter {
error: null,
};

private readonly startupProbe: DesktopDiagnosticsSnapshot["startupProbe"] = {
preloadSeen: false,
rendererSeen: false,
entries: [],
};

private sleepGuard: SleepGuardSnapshot = createInitialSleepGuardSnapshot();

private readonly renderer: DesktopRendererSnapshot = {
Expand Down Expand Up @@ -152,6 +172,33 @@ export class DesktopDiagnosticsReporter {
this.scheduleFlush();
}

recordStartupProbe(payload: StartupProbePayload): void {
if (payload.source === "preload") {
this.startupProbe.preloadSeen = true;
}

if (payload.source === "renderer") {
this.startupProbe.rendererSeen = true;
}

this.startupProbe.entries.push({
source: payload.source,
stage: payload.stage,
status: payload.status,
detail: payload.detail ?? null,
at: nowIso(),
});

if (this.startupProbe.entries.length > MAX_STARTUP_PROBE_ENTRIES) {
this.startupProbe.entries.splice(
0,
this.startupProbe.entries.length - MAX_STARTUP_PROBE_ENTRIES,
);
}

this.scheduleFlush();
}

recordRendererDidFinishLoad(url: string): void {
this.renderer.didFinishLoad = true;
this.renderer.lastUrl = url;
Expand Down Expand Up @@ -277,6 +324,11 @@ export class DesktopDiagnosticsReporter {
updatedAt: nowIso(),
isPackaged: app.isPackaged,
coldStart: { ...this.coldStart },
startupProbe: {
preloadSeen: this.startupProbe.preloadSeen,
rendererSeen: this.startupProbe.rendererSeen,
entries: this.startupProbe.entries.map((entry) => ({ ...entry })),
},
sleepGuard: {
...this.sleepGuard,
counters: { ...this.sleepGuard.counters },
Expand Down
203 changes: 191 additions & 12 deletions apps/desktop/main/diagnostics-export.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execFileSync } from "node:child_process";
import { spawnSync } from "node:child_process";
import type { Dirent } from "node:fs";
import { access, readFile, readdir, stat, writeFile } from "node:fs/promises";
import { homedir, hostname } from "node:os";
Expand Down Expand Up @@ -167,6 +167,14 @@ function redactJsonBuffer(raw: Buffer): Buffer {
}
}

function parseJsonBuffer<T>(raw: Buffer): T | null {
try {
return JSON.parse(raw.toString("utf8")) as T;
} catch {
return null;
}
}

// ---------------------------------------------------------------------------
// Artifact collection
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -229,19 +237,109 @@ async function listFilesRecursive(directoryPath: string): Promise<string[]> {
return output;
}

function runCommand(
binaryPath: string,
args: string[],
): {
binaryPath: string;
args: string[];
ok: boolean;
status: number | null;
signal: NodeJS.Signals | null;
stdout: string | null;
stderr: string | null;
error: string | null;
} {
const result = spawnSync(binaryPath, args, {
encoding: "utf8",
timeout: 5000,
});

const stdout = result.stdout.trim();
const stderr = result.stderr.trim();

return {
binaryPath,
args,
ok: result.status === 0 && !result.error,
status: result.status,
signal: result.signal,
stdout: stdout.length > 0 ? stdout : null,
stderr: stderr.length > 0 ? stderr : null,
error: result.error ? String(result.error.message) : null,
};
}
Comment thread
nettee marked this conversation as resolved.

function readMacOsProductVersion(): string | null {
if (process.platform !== "darwin") {
return null;
}

try {
const output = execFileSync("sw_vers", ["-productVersion"], {
encoding: "utf8",
}).trim();
return output.length > 0 ? output : null;
} catch {
const result = runCommand("/usr/bin/sw_vers", ["-productVersion"]);
return result.ok ? result.stdout : null;
}

function buildMachineSummary(runtimeConfig: DesktopRuntimeConfig): object {
const rosettaCheck =
process.platform === "darwin"
? runCommand("/usr/sbin/sysctl", ["-n", "sysctl.proc_translated"])
: null;

const unameMachine =
process.platform === "darwin" ? runCommand("/usr/bin/uname", ["-m"]) : null;

return {
buildInfo: runtimeConfig.buildInfo,
hostName: hostname(),
platform: process.platform,
arch: process.arch,
osVersion: readMacOsProductVersion(),
processVersions: process.versions,
executablePath: app.getPath("exe"),
processExecPath: process.execPath,
resourcesPath: process.resourcesPath,
isPackaged: app.isPackaged,
rosetta: rosettaCheck
? {
translated:
rosettaCheck.ok && rosettaCheck.stdout !== null
? rosettaCheck.stdout === "1"
: null,
command: rosettaCheck,
}
: null,
uname: unameMachine,
appPaths: {
userData: app.getPath("userData"),
logs: app.getPath("logs"),
crashDumps: app.getPath("crashDumps"),
nexuHome: runtimeConfig.paths.nexuHome,
},
};
}

function buildAppSigningSummary(): object | null {
if (process.platform !== "darwin") {
return null;
}

const appExecutablePath = app.getPath("exe");

return {
executablePath: appExecutablePath,
codesign: runCommand("/usr/bin/codesign", [
"-dv",
"--verbose=4",
appExecutablePath,
]),
spctl: runCommand("/usr/sbin/spctl", [
"--assess",
"--type",
"execute",
"-vv",
appExecutablePath,
]),
};
}

function getTimestampSlug(): string {
Expand All @@ -265,6 +363,7 @@ async function collectArtifacts(
const included: string[] = [];
const missing: string[] = [];
const warnings: string[] = [];
let desktopDiagnosticsSummary: unknown = null;

const additionalArtifacts = {
startupHealth: null as CollectedFileMetadata | null,
Expand Down Expand Up @@ -305,14 +404,59 @@ async function collectArtifacts(
}

// Desktop diagnostics snapshot
await addFile(
const desktopDiagnosticsMetadata = await addFile(
"diagnostics/desktop-diagnostics.json",
getDesktopDiagnosticsFilePath(),
{
redact: true,
},
);

if (desktopDiagnosticsMetadata) {
const desktopDiagnosticsFile = await tryReadFile(
getDesktopDiagnosticsFilePath(),
);
const parsedDiagnostics = desktopDiagnosticsFile
? parseJsonBuffer<{
startupProbe?: {
preloadSeen?: boolean;
rendererSeen?: boolean;
entries?: Array<{
source?: string;
stage?: string;
status?: string;
detail?: string | null;
at?: string;
}>;
};
renderer?: {
didFinishLoad?: boolean;
lastError?: string | null;
processGone?: {
seen?: boolean;
reason?: string | null;
exitCode?: number | null;
at?: string | null;
};
};
coldStart?: {
status?: string;
step?: string | null;
error?: string | null;
};
}>(desktopDiagnosticsFile.data)
: null;

if (parsedDiagnostics) {
desktopDiagnosticsSummary = {
sourceArchivePath: desktopDiagnosticsMetadata.archivePath,
coldStart: parsedDiagnostics.coldStart ?? null,
renderer: parsedDiagnostics.renderer ?? null,
startupProbe: parsedDiagnostics.startupProbe ?? null,
};
}
}

// Main process logs
const logsDir = resolve(app.getPath("userData"), "logs");
await addFile("logs/cold-start.log", resolve(logsDir, "cold-start.log"), {
Expand Down Expand Up @@ -440,6 +584,8 @@ async function collectArtifacts(

// Environment summary (safe metadata only)
const envSummary = buildEnvironmentSummary(runtimeConfig);
const machineSummary = buildMachineSummary(runtimeConfig);
const appSigningSummary = buildAppSigningSummary();
const now = new Date();
entries.push({
name: `${archiveRoot}/summary/environment-summary.json`,
Expand All @@ -448,6 +594,37 @@ async function collectArtifacts(
});
included.push("summary/environment-summary.json");

entries.push({
name: `${archiveRoot}/summary/machine-info.json`,
data: Buffer.from(`${JSON.stringify(machineSummary, null, 2)}\n`, "utf8"),
modTime: now,
});
included.push("summary/machine-info.json");

if (appSigningSummary) {
entries.push({
name: `${archiveRoot}/summary/app-signing.json`,
data: Buffer.from(
`${JSON.stringify(appSigningSummary, null, 2)}\n`,
"utf8",
),
modTime: now,
});
included.push("summary/app-signing.json");
}

if (desktopDiagnosticsSummary) {
entries.push({
name: `${archiveRoot}/summary/startup-probe-summary.json`,
data: Buffer.from(
`${JSON.stringify(desktopDiagnosticsSummary, null, 2)}\n`,
"utf8",
Comment thread
nettee marked this conversation as resolved.
Outdated
),
modTime: now,
});
included.push("summary/startup-probe-summary.json");
}

const extraArtifactsSummary = {
startupHealth: additionalArtifacts.startupHealth,
openclawLogs: additionalArtifacts.openclawLogs,
Expand All @@ -465,6 +642,12 @@ async function collectArtifacts(
});
included.push("summary/additional-artifacts.json");

if (missing.length > 0) {
warnings.push(`${missing.length} file(s) were not found and were skipped.`);
}

included.push("summary/manifest.json");

// Manifest
const manifest = {
exportedAt: now.toISOString(),
Expand All @@ -482,10 +665,6 @@ async function collectArtifacts(
modTime: now,
});

if (missing.length > 0) {
warnings.push(`${missing.length} file(s) were not found and were skipped.`);
}

return { entries, warnings };
}

Expand Down
Loading
Loading