feat(diagnostics): persistent redacted logging and an exportable bundle
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 22m12s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Successful in 37s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 11s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m10s

The app forgot everything it did the moment it exited. The Rust half
logged through env_logger to stdout only -- invisible to anyone who
launched from a desktop icon, and on Android worse than that: stdout is
not logcat, so the backend produced no visible output at all on the
platform carrying this project's hardest bugs. The autoplay deadlock,
the truncated-stream restart and the background-audio stall were all
diagnosed by talking a user through `adb logcat`, because there was no
other way to see anything. A panic left nothing behind at all.

Logs now go to a size-capped rotating file, to logcat on Android, and to
the webview console in dev. A panic is recorded with its backtrace before
the process dies. The frontend's messages are forwarded into the same
file, so one timeline holds both halves of the app in order -- which is
what makes a race between them legible after the fact, and races between
them are the expensive bug class here.

Redaction runs in the log FORMATTER, not at export time. A credential
sitting in a file on the device is already a disclosure; stripping it on
the way out would be too late. The exporter redacts a second time to
cover files written by builds that predate this. api_key, X-Emby-Token,
Authorization, "AccessToken" and Token="..." all reduce to [REDACTED],
while host, item ids and filenames are deliberately kept -- a log scrubbed
of those is one nobody can debug anything from. Server URLs keep scheme
and host and drop any embedded user:pass@.

Two things the tests caught that review would not have:

  - redact_headers recursed on its own output. The replacement keeps the
    header NAME, so the next call matched the same header forever; the
    test died with a stack overflow. It is a forward scan now.
  - The frontend forwarder used `void plugin.error(...)`. `void` discards
    a promise's value but not its rejection, so in any webview without
    IPC -- a unit test, SSR, a browser preview -- every log line became an
    unhandled rejection. 20 of them showed up the first time coverage
    ran. Each call now attaches a catch.

Only info and above cross the IPC boundary: debug is per-tick player
state and forwarding it would be thousands of calls a minute for output
nobody reads. A failing forwarder never propagates and never prevents the
console write.

Nothing is transmitted anywhere. The export writes a zip and reports its
path; the user attaches it themselves, which is also what keeps this from
becoming telemetry. An Android share intent is explicitly out of scope --
it is Kotlin work that belongs with the other native code.

The panic hook chains to the previous hook rather than replacing it,
because utils/lock.rs installs a silencing hook around tests that provoke
poisoned locks on purpose.

Spec in docs/specs/diagnostics-and-logging.md; UR-078 / DR-218 / UT-209.

Verified: 1079 frontend tests and the coverage gate, 759 Rust tests,
clippy -D warnings, svelte-check 0 errors, and cargo check for
aarch64-linux-android.
This commit is contained in:
2026-08-21 18:58:57 +02:00
parent fb72bf3005
commit f3fa45f742
16 changed files with 1758 additions and 9 deletions
+136 -1
View File
@@ -11,6 +11,8 @@
StreamingQuality,
VideoSettings,
VolumeLevel,
DiagnosticsInfo,
DiagnosticsBundle,
} from "$lib/api/bindings";
import {
getCacheStats,
@@ -30,7 +32,7 @@
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
import { createLogger } from "$lib/utils/logger";
import { openUrl } from "@tauri-apps/plugin-opener";
import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
import {
checkForUpdate,
installUpdate,
@@ -153,6 +155,7 @@
// APK, so it is offered the releases page instead of an install button.
const { platform } = await import("@tauri-apps/plugin-os");
canInstallUpdates = updateCapability(platform()) === "install";
await loadDiagnostics();
});
async function loadSettings() {
@@ -480,6 +483,59 @@
updateState = "failed";
}
}
// ---------------------------------------------------------------------
// Diagnostics
//
// The app used to forget everything it did on exit, which is why several
// playback bugs here needed multiple rounds of "can you reproduce it under
// adb logcat". Logs are now on disk, redacted, and exportable as one file to
// attach to a bug report. Nothing is transmitted anywhere.
//
// TRACES: UR-078 | DR-218
let diagnosticsInfo = $state<DiagnosticsInfo | null>(null);
let exportedBundle = $state<DiagnosticsBundle | null>(null);
let exporting = $state(false);
let exportError = $state<string | null>(null);
const LOG_LEVELS = ["error", "warn", "info", "debug", "trace"] as const;
async function loadDiagnostics() {
try {
diagnosticsInfo = await commands.diagnosticsGetInfo();
} catch (e) {
// A settings page that cannot read the log level is still a usable
// settings page.
log.warn("Failed to read diagnostics info:", e);
}
}
async function handleLogLevelChange(level: string) {
try {
await commands.diagnosticsSetLevel(level);
await loadDiagnostics();
} catch (e) {
log.error("Failed to set log level:", e);
}
}
async function handleExportDiagnostics() {
exporting = true;
exportError = null;
exportedBundle = null;
try {
// The server URL is passed so the backend can record its scheme and host
// in the bundle. It reduces it to those two parts — the token that may be
// on the URL never reaches the file.
const serverUrl = auth.getServerUrl();
exportedBundle = await commands.diagnosticsExport(serverUrl);
await loadDiagnostics();
} catch (e) {
log.error("Diagnostics export failed:", e);
exportError = String(e);
} finally {
exporting = false;
}
}
</script>
<div class="max-w-2xl mx-auto space-y-8 p-6">
@@ -1157,6 +1213,85 @@
</div>
</div>
<!-- Diagnostics.
Logs live on disk, redacted, and export as one file for a bug
report. Nothing is transmitted; the user attaches it themselves.
TRACES: UR-078 | DR-218 -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-2xl font-bold text-white mb-4">Diagnostics</h2>
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-6">
<div>
<h3 class="text-lg font-semibold text-white mb-1">Detail level</h3>
<p class="text-sm text-gray-400 mb-3">
Higher detail helps diagnose a problem but writes more to disk. The setting survives a
restart, so you can turn it up and then reproduce the bug.
</p>
<div class="flex flex-wrap gap-2">
{#each LOG_LEVELS as level (level)}
<button
class="px-3 py-1.5 rounded-lg text-sm font-medium capitalize {diagnosticsInfo?.level ===
level
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-gray-700 text-gray-300'}"
onclick={() => handleLogLevelChange(level)}
>
{level}
</button>
{/each}
</div>
</div>
<div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="text-lg font-semibold text-white">Export diagnostics</h3>
<p class="text-sm text-gray-400 mt-1">
Bundles the logs and your app/OS versions into one file to attach to a bug report.
Access tokens and passwords are removed; nothing is uploaded anywhere.
</p>
</div>
<button
class="px-4 py-2 rounded-lg bg-[var(--color-jellyfin)] text-white font-medium disabled:opacity-50 whitespace-nowrap"
onclick={handleExportDiagnostics}
disabled={exporting}
>
{exporting ? "Exporting…" : "Export"}
</button>
</div>
{#if exportedBundle}
<div class="mt-3 border border-gray-700 rounded-lg p-3 space-y-2">
<p class="text-sm text-green-400">
Saved {formatBytes(exportedBundle.sizeBytes)} from {exportedBundle.fileCount} log file{exportedBundle.fileCount ===
1
? ""
: "s"}.
</p>
<p class="text-xs text-gray-400 break-all font-mono">{exportedBundle.path}</p>
{#if canInstallUpdates}
<button
class="text-sm text-[var(--color-jellyfin)] underline"
onclick={() => revealItemInDir(exportedBundle!.path)}
>
Show in folder
</button>
{/if}
</div>
{:else if exportError}
<p class="mt-3 text-sm text-yellow-400">Couldn't export: {exportError}</p>
{/if}
</div>
{#if diagnosticsInfo}
<div class="text-xs text-gray-500 space-y-1">
<p class="font-mono break-all">{diagnosticsInfo.logDir}</p>
<p>{formatBytes(diagnosticsInfo.totalSizeBytes)} of logs held</p>
</div>
{/if}
</div>
</div>
<!-- Updates.
Desktop installs in place; Android can only be pointed at the
releases page, because an app may not replace its own APK. The