-
Notifications
You must be signed in to change notification settings - Fork 154
Expand file tree
/
Copy pathdiagnostics.ts
More file actions
286 lines (261 loc) · 10.9 KB
/
Copy pathdiagnostics.ts
File metadata and controls
286 lines (261 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
/**
* Crash reporting + diagnostics export for the Electron main process.
*
* Error reporting is Sentry-backed and gated entirely on a DSN being baked
* in at build time (publish-desktop.yml exports DESKTOP_SENTRY_DSN; see the define
* in electron.vite.config.ts). Local/dev builds have no DSN, so nothing is
* ever sent — instead Electron's native crash reporter still writes
* minidumps locally so they ride along in the diagnostics zip.
*
* What reaches Sentry when enabled:
* - main-process uncaught exceptions / unhandled rejections
* - native minidumps (main, renderer, GPU) via the Crashpad integration
* - renderer/child process terminations (render-process-gone et al.)
* - sidecar crashes after a successful boot, with a stderr tail
*
* What never leaves the machine: executor data (~/.executor — data.db holds
* user secrets) and the desktop settings password. The diagnostics zip only
* packs log files, crash dumps, and a redacted manifest.
*/
import { readdirSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { app, crashReporter, dialog, shell } from "electron";
import log from "electron-log/main.js";
import * as Sentry from "@sentry/electron/main";
import { getServerSettings } from "./settings";
// Packaged builds always use the DSN baked in at build time. The non-packaged
// override is a test seam: the desktop e2e points crash reporting at a local
// sink so it can assert what is (and isn't) reported. It can never redirect a
// real user's reports — production is always packaged, where the override is
// dead and the baked DSN wins.
const sentryDsn =
(app.isPackaged ? undefined : process.env.EXECUTOR_DESKTOP_SENTRY_DSN) || __EXECUTOR_SENTRY_DSN__;
// The informal cross-tool opt-out (consoledonottrack.com). Checked before
// any SDK initializes, and it covers all three processes because the
// renderer and sidecar both receive their config from this module.
const doNotTrack =
process.env.DO_NOT_TRACK === "1" || process.env.DO_NOT_TRACK?.toLowerCase() === "true";
export const errorReportingEnabled = sentryDsn.length > 0 && !doNotTrack;
/**
* One id per app launch, shared by every process (main, renderer, sidecar)
* and stamped into the diagnostics manifest — lets a user-sent zip be
* matched to its Sentry events and vice versa.
*/
export const runId = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
const releaseTag = () => `executor-desktop@${app.getVersion()}`;
const environmentTag = () => (app.isPackaged ? "production" : "development");
/**
* Runtime crash-reporting config for the renderer (fetched over the preload
* bridge). The web UI is the same bundle `executor web` serves, so nothing
* is baked in at build time — outside the desktop app this returns null and
* the renderer never initializes Sentry.
*/
export const getCrashReportingConfig = () =>
errorReportingEnabled
? {
dsn: sentryDsn,
release: releaseTag(),
environment: environmentTag(),
runId,
}
: null;
/** Env vars handed to the sidecar so its process reports under the same id. */
export const sidecarCrashReportingEnv = (): Record<string, string> =>
errorReportingEnabled
? {
EXECUTOR_SENTRY_DSN: sentryDsn,
EXECUTOR_SENTRY_RELEASE: releaseTag(),
EXECUTOR_SENTRY_ENVIRONMENT: environmentTag(),
EXECUTOR_RUN_ID: runId,
}
: {};
/**
* Must run before `app.whenReady()` so the Crashpad handler attaches to
* every child process Electron spawns.
*/
export const initErrorReporting = () => {
if (errorReportingEnabled) {
Sentry.init({
dsn: sentryDsn,
release: releaseTag(),
environment: environmentTag(),
initialScope: {
tags: {
platform: process.platform,
arch: process.arch,
runId,
},
},
});
} else {
// No DSN baked in — keep native crash dumps local so a user-reported
// crash still leaves minidumps for the diagnostics zip to collect.
crashReporter.start({ uploadToServer: false, compress: true });
}
// Persist process-death signals to main.log regardless of Sentry — these
// are the events a "the app just disappeared" report hinges on. Sentry's
// ChildProcess integration reports them upstream; this keeps a local copy.
app.on("child-process-gone", (_event, details) => {
log.error("[crash] child process gone", details);
});
app.on("render-process-gone", (_event, webContents, details) => {
log.error("[crash] render process gone", { url: webContents.getURL(), ...details });
});
// Main-process uncaught errors: electron-log writes them to main.log and
// keeps the process alive (matching its default), Sentry (when enabled)
// captures them via its own integrations.
log.errorHandler.startCatching({ showDialog: false });
// Every log line becomes a Sentry breadcrumb, so an error event arrives
// with the recent log context (sidecar restarts, update checks, …) instead
// of a bare stack. Hooked on the file transport only so each line is
// recorded once. No-ops when Sentry is disabled.
log.hooks.push((message, transport) => {
if (transport !== log.transports.file) return message;
Sentry.addBreadcrumb({
category: message.scope ?? "main",
level: message.level === "warn" ? "warning" : message.level === "error" ? "error" : "info",
message: message.data
.map((part) => (typeof part === "string" ? part : JSON.stringify(part)))
.join(" ")
.slice(0, 1024),
});
return message;
});
};
/**
* Report a sidecar crash that happened after a successful boot. The startup
* path already surfaces its own dialog; this covers the "server died under
* a running window" case, which is otherwise invisible.
*/
export const reportSidecarCrash = (message: string, stderrTail: string) => {
// No-op when Sentry isn't initialized — captures are dropped client-side.
Sentry.captureMessage(message, {
level: "error",
extra: { stderrTail },
});
};
// ---------------------------------------------------------------------------
// Diagnostics export — one zip in ~/Downloads a user can attach to a report.
// ---------------------------------------------------------------------------
const MAX_EXPORT_FILE_BYTES = 50 * 1024 * 1024;
const EXPORT_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
interface ZipEntry {
readonly name: string;
readonly path: string;
}
/** Recursively list files under `dir`, capped by size and age. */
const collectFiles = (dir: string, prefix: string): ZipEntry[] => {
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fs probing of optional directories (crash dumps may not exist)
try {
const cutoff = Date.now() - EXPORT_MAX_AGE_MS;
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = join(dir, entry.name);
if (entry.isDirectory()) return collectFiles(full, `${prefix}/${entry.name}`);
if (!entry.isFile()) return [];
const info = statSync(full);
if (info.size > MAX_EXPORT_FILE_BYTES) return [];
if (info.mtimeMs < cutoff) return [];
return [{ name: `${prefix}/${entry.name}`, path: full }];
});
} catch {
return [];
}
};
const exportStamp = () =>
new Date()
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d+Z$/, "Z");
const buildManifest = () => {
const settings = getServerSettings();
return {
generated: new Date().toISOString(),
app: app.getName(),
runId,
version: app.getVersion(),
packaged: app.isPackaged,
platform: process.platform,
arch: process.arch,
versions: process.versions,
uptimeSeconds: Math.round(process.uptime()),
errorReportingEnabled,
paths: {
userData: app.getPath("userData"),
logs: dirname(log.transports.file.getFile().path),
crashDumps: app.getPath("crashDumps"),
},
// The bearer token is never included — it stays in auth.json on the machine.
serverSettings: {
port: settings.port,
},
};
};
/**
* Pack manifest + electron-log files + sidecar log + crash dumps into
* `~/Downloads/executor-diagnostics-<stamp>.zip` and reveal it in the file
* manager. Returns the zip path.
*/
export const exportDiagnostics = async (): Promise<string> => {
const { TextReader, Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } =
await import("@zip.js/zip.js");
const { readFile } = await import("node:fs/promises");
const logsDir = dirname(log.transports.file.getFile().path);
const entries: ZipEntry[] = [
...collectFiles(logsDir, "logs"),
...collectFiles(app.getPath("crashDumps"), "crash-dumps"),
];
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add("manifest.json", new TextReader(JSON.stringify(buildManifest(), null, 2)));
for (const entry of entries) {
await writer.add(entry.name, new Uint8ArrayReader(new Uint8Array(await readFile(entry.path))));
}
const zipped = await writer.close();
const output = join(app.getPath("downloads"), `executor-diagnostics-${exportStamp()}.zip`);
writeFileSync(output, zipped);
log.info("[diagnostics] exported", { output, files: entries.length });
shell.showItemInFolder(output);
return output;
};
/**
* "Report a Problem…" menu flow: export the diagnostics zip, then open a
* prefilled GitHub issue. The zip is revealed in the file manager so the
* user can drag it onto the issue; nothing is uploaded automatically.
*/
export const reportAProblem = async () => {
await exportDiagnosticsInteractive();
const body = [
"<!-- Describe what happened and what you expected. -->",
"",
"",
"---",
"",
"| | |",
"|---|---|",
`| Version | ${app.getVersion()} |`,
`| OS | ${process.platform} ${process.arch} |`,
`| Run ID | ${runId} |`,
"",
"_A diagnostics zip was saved to your Downloads folder — please drag it into this issue._",
].join("\n");
const url = new URL("https://github.com/RhysSullivan/executor/issues/new");
url.searchParams.set("title", "[desktop] ");
url.searchParams.set("body", body);
await shell.openExternal(url.toString());
};
/** Menu-item wrapper: surface failures in a dialog instead of dying silently. */
export const exportDiagnosticsInteractive = async () => {
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: user-initiated export surfaces failures in a native dialog
try {
await exportDiagnostics();
} catch (error) {
log.error("[diagnostics] export failed", error);
// oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: fs/zip failures arrive as plain Node errors and render in a native dialog
const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
await dialog.showMessageBox({
type: "error",
title: "Diagnostics export failed",
message: "Couldn't write the diagnostics zip.",
detail: `${detail.slice(0, 1200)}\n\nLogs live at: ${dirname(log.transports.file.getFile().path)}`,
});
}
};