diagnostics: panic hook + frontend error capture + Settings → About diagnostic report

THE BRIEF:
For the friends beta we need verbal feedback AND technical feedback —
some bugs the user cannot describe but a stack trace can. Built it in
two layers, with Layer 3 deferred until there is real volume.

PRIVACY POSTURE (matches Kon's local-first positioning):
- All capture is to disk only. Nothing is transmitted.
- The manual report bundler shows the user exactly what would be
  shared and lets them choose to copy / save it.
- No remote endpoint, no Sentry, no opt-out telemetry.

LAYER 1 — Always-on local capture:
- Rust panic hook (commands/diagnostics.rs::install_panic_hook) writes
  each panic to ~/.kon/crashes/<unix-ts>-<short-id>.crash. Captures
  thread name, OS/arch, RUST_BACKTRACE state, and the panic info.
  Installed before tauri::Builder so it catches setup-time panics too.
- Frontend window.onerror + window.unhandledrejection in
  src/routes/+layout.svelte forward to the new log_frontend_error
  Tauri command, which inserts into the existing error_log SQLite
  table. Best-effort: errors in the error handler itself are swallowed
  so logging can never crash the app.
- Existing error_log table (migration v1) is now actually used —
  closes the TODO from crates/storage/src/database.rs:409.

LAYER 2 — Manual diagnostic report:
- Settings → About → Diagnostics section: Generate report → Preview →
  Copy / Save.
- Report is plain markdown so it pastes cleanly into email, Discord,
  GitHub issues. Sections: app version + OS, sanitised settings JSON,
  recent error_log rows, last 5 crash dumps with previews, log file
  tail (8KB).
- Preview is shown in a <details> with the full text the user can
  inspect before deciding to share.
- Save writes to ~/.kon/diagnostic-reports/kon-diagnostic-<ts>.md.

NEW FILES:
- src-tauri/src/commands/diagnostics.rs (panic hook + 5 Tauri commands)

CHANGES:
- crates/storage/src/file_storage.rs: crashes_dir() + logs_dir() helpers
- crates/storage/src/database.rs: ErrorLogRow + list_recent_errors()
- crates/storage/src/lib.rs: re-exports
- src-tauri/src/commands/mod.rs: + diagnostics module
- src-tauri/src/lib.rs: install_panic_hook() before Builder; 5 new
  Tauri commands registered (log_frontend_error,
  list_recent_errors_command, list_crash_files,
  generate_diagnostic_report, save_diagnostic_report)
- src/routes/+layout.svelte: installGlobalErrorCapture() at mount
- src/lib/pages/SettingsPage.svelte: diagnostic state + buttons +
  preview block at the end of the About section

cargo check -p kon-storage clean. Settings.svelte if/each balanced.

LAYER 3 (deferred):
- Optional opt-in remote reporting (self-hosted Sentry on Tartarus or
  similar). Not for friends beta. Open up after volume justifies it.
This commit is contained in:
2026-04-17 13:47:20 +01:00
parent 524cab0d98
commit 62ea58d95a
10 changed files with 810 additions and 61 deletions

View File

@@ -86,6 +86,63 @@
vocabularyError = "Could not delete term: " + (err?.message || err);
}
}
// --- Diagnostics (Settings → About → Generate diagnostic report) ---
// Layer 2 of the diagnostics plan: user previews exactly what would be
// shared, then chooses to copy / save. Nothing leaves the machine
// automatically.
let diagnosticReport = $state("");
let diagnosticReportError = $state(null);
let diagnosticReportSavedTo = $state(null);
let diagnosticReportLoading = $state(false);
async function generateDiagnosticReport() {
diagnosticReportError = null;
diagnosticReportSavedTo = null;
diagnosticReportLoading = true;
try {
diagnosticReport = await invoke("generate_diagnostic_report", {
options: {
includeRecentErrors: true,
includeCrashes: true,
includeSettings: true,
includeLogTail: true,
},
});
} catch (err) {
diagnosticReportError = "Could not generate report: " + (err?.message || err);
} finally {
diagnosticReportLoading = false;
}
}
async function copyDiagnosticReport() {
if (!diagnosticReport) return;
try {
await navigator.clipboard.writeText(diagnosticReport);
diagnosticReportSavedTo = "Copied to clipboard.";
} catch (err) {
diagnosticReportError = "Clipboard copy failed: " + (err?.message || err);
}
}
async function saveDiagnosticReport() {
diagnosticReportError = null;
try {
const path = await invoke("save_diagnostic_report", {
options: {
includeRecentErrors: true,
includeCrashes: true,
includeSettings: true,
includeLogTail: true,
},
});
diagnosticReportSavedTo = "Saved to " + path;
} catch (err) {
diagnosticReportError = "Save failed: " + (err?.message || err);
}
}
let parakeetDownloaded = $state(false);
let parakeetDownloading = $state(false);
let parakeetProgress = $state(0);
@@ -1036,6 +1093,46 @@
</div>
<p class="text-[11px] text-text-tertiary mt-5">Kon v1.0 · Powered by whisper.cpp · Built by CORBEL Ltd</p>
<!-- Diagnostic report (privacy posture: local only until you share it) -->
<div class="mt-6 pt-5 border-t border-border-subtle">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Diagnostics</p>
<p class="text-[11px] text-text-tertiary mb-3">
If something goes wrong, generate a diagnostic report to share with the developer. The report contains your settings, recent errors, any crash dumps, and the tail of the log file. Nothing is sent automatically — you preview it first, then choose copy or save.
</p>
<div class="flex gap-2 mb-3">
<button
type="button"
onclick={generateDiagnosticReport}
disabled={diagnosticReportLoading}
class="px-3 py-2 text-[12px] bg-accent text-bg rounded-lg disabled:opacity-50 hover:bg-accent-hover"
>{diagnosticReportLoading ? "Generating…" : "Generate report"}</button>
{#if diagnosticReport}
<button
type="button"
onclick={copyDiagnosticReport}
class="px-3 py-2 text-[12px] text-text border border-border rounded-lg hover:border-accent hover:text-accent"
>Copy to clipboard</button>
<button
type="button"
onclick={saveDiagnosticReport}
class="px-3 py-2 text-[12px] text-text border border-border rounded-lg hover:border-accent hover:text-accent"
>Save as file</button>
{/if}
</div>
{#if diagnosticReportError}
<p class="text-[11px] text-error mb-2">{diagnosticReportError}</p>
{/if}
{#if diagnosticReportSavedTo}
<p class="text-[11px] text-success mb-2">{diagnosticReportSavedTo}</p>
{/if}
{#if diagnosticReport}
<details class="text-[11px]">
<summary class="cursor-pointer text-text-secondary hover:text-text">Preview report ({diagnosticReport.length} chars)</summary>
<pre class="mt-2 p-3 bg-bg-input border border-border rounded-lg text-[10px] text-text-secondary overflow-auto max-h-[400px] whitespace-pre-wrap font-mono">{diagnosticReport}</pre>
</details>
{/if}
</div>
</div>
{/if}
</div>