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
🏗️ 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:
@@ -1763,6 +1763,36 @@ async playlistRemoveItems(handle: string, playlistId: string, entryIds: string[]
|
||||
async playlistMoveItem(handle: string, playlistId: string, itemId: string, newIndex: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("playlist_move_item", { handle, playlistId, itemId, newIndex });
|
||||
},
|
||||
/**
|
||||
* Current log level and where the files are.
|
||||
*
|
||||
* TRACES: UR-078 | DR-218
|
||||
*/
|
||||
async diagnosticsGetInfo() : Promise<DiagnosticsInfo> {
|
||||
return await TAURI_INVOKE("diagnostics_get_info");
|
||||
},
|
||||
/**
|
||||
* Set the log level, for this session and the next.
|
||||
*
|
||||
* TRACES: UR-078 | DR-218
|
||||
*/
|
||||
async diagnosticsSetLevel(level: string) : Promise<string> {
|
||||
return await TAURI_INVOKE("diagnostics_set_level", { level });
|
||||
},
|
||||
/**
|
||||
* Write a redacted diagnostics archive and return where it went.
|
||||
*
|
||||
* # Blocking I/O
|
||||
*
|
||||
* This reads and rewrites every log file. It is an `async` command so it does
|
||||
* not block the IPC thread, but it must never be called from a player event
|
||||
* callback — see the deadlock note in CLAUDE.md.
|
||||
*
|
||||
* TRACES: UR-078 | DR-218
|
||||
*/
|
||||
async diagnosticsExport(serverUrl: string | null) : Promise<DiagnosticsBundle> {
|
||||
return await TAURI_INVOKE("diagnostics_export", { serverUrl });
|
||||
},
|
||||
/**
|
||||
* Format time in seconds to MM:SS display string
|
||||
*
|
||||
@@ -2037,6 +2067,34 @@ connectionError: string | null;
|
||||
* Whether we're currently checking connectivity
|
||||
*/
|
||||
isChecking: boolean }
|
||||
/**
|
||||
* Where an export landed, so the UI can tell the user where to find it.
|
||||
*/
|
||||
export type DiagnosticsBundle = {
|
||||
/**
|
||||
* Absolute path to the written archive.
|
||||
*/
|
||||
path: string; sizeBytes: number;
|
||||
/**
|
||||
* How many log files went in, excluding the environment summary.
|
||||
*/
|
||||
fileCount: number }
|
||||
/**
|
||||
* Where logs live and how verbose they currently are.
|
||||
*/
|
||||
export type DiagnosticsInfo = {
|
||||
/**
|
||||
* Directory holding the rotating log files.
|
||||
*/
|
||||
logDir: string;
|
||||
/**
|
||||
* Active level, lowercase: "error" | "warn" | "info" | "debug" | "trace".
|
||||
*/
|
||||
level: string;
|
||||
/**
|
||||
* Total bytes currently held by log files.
|
||||
*/
|
||||
totalSizeBytes: number }
|
||||
/**
|
||||
* On-disk usage of downloaded content, for the Downloads surface.
|
||||
*
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
resetLogLevel,
|
||||
resolveDefaultLogLevel,
|
||||
setLogLevel,
|
||||
setLogForwarder,
|
||||
shouldForward,
|
||||
formatForForwarding,
|
||||
type LogLevel,
|
||||
} from "./logger";
|
||||
|
||||
@@ -342,3 +345,106 @@ describe("resolveDefaultLogLevel", () => {
|
||||
expect(resolveDefaultLogLevel(false, false)).toBe("warn");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Forwarding a copy of each message into the Rust log sink.
|
||||
*
|
||||
* TRACES: | DR-218 | UT-209
|
||||
*/
|
||||
describe("log forwarding", () => {
|
||||
afterEach(() => {
|
||||
setLogForwarder(null);
|
||||
resetLogLevel();
|
||||
});
|
||||
|
||||
it("persists info and above, but not debug", () => {
|
||||
// debug is per-tick player state; forwarding it would be thousands of IPC
|
||||
// calls a minute for output nobody reads.
|
||||
expect(shouldForward("debug")).toBe(false);
|
||||
expect(shouldForward("info")).toBe(true);
|
||||
expect(shouldForward("warn")).toBe(true);
|
||||
expect(shouldForward("error")).toBe(true);
|
||||
});
|
||||
|
||||
it("hands the forwarder a tagged, stringified line", () => {
|
||||
const seen: Array<[string, string]> = [];
|
||||
setLogForwarder((level, message) => seen.push([level, message]));
|
||||
setLogLevel("debug");
|
||||
|
||||
const log = createLogger("Player");
|
||||
log.info("advancing to", { itemId: "4f2a" });
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0][0]).toBe("info");
|
||||
expect(seen[0][1]).toContain("[Player]");
|
||||
expect(seen[0][1]).toContain("advancing to");
|
||||
expect(seen[0][1]).toContain("4f2a");
|
||||
});
|
||||
|
||||
it("does not forward a message the level filter already suppressed", () => {
|
||||
const seen: string[] = [];
|
||||
setLogForwarder((_level, message) => seen.push(message));
|
||||
setLogLevel("error");
|
||||
|
||||
createLogger("Player").info("this should not be recorded anywhere");
|
||||
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not forward debug even when debug is being displayed", () => {
|
||||
const seen: string[] = [];
|
||||
setLogForwarder((_level, message) => seen.push(message));
|
||||
setLogLevel("debug");
|
||||
|
||||
const log = createLogger("Player");
|
||||
log.debug("per-tick position 12.4");
|
||||
log.info("kept");
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]).toContain("kept");
|
||||
});
|
||||
|
||||
it("does not let a failing forwarder break the caller", () => {
|
||||
// A failure to log must never become an application failure.
|
||||
setLogForwarder(() => {
|
||||
throw new Error("sink is on fire");
|
||||
});
|
||||
setLogLevel("debug");
|
||||
|
||||
const log = createLogger("Player");
|
||||
expect(() => log.error("something went wrong")).not.toThrow();
|
||||
});
|
||||
|
||||
it("still writes to the console when forwarding throws", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setLogForwarder(() => {
|
||||
throw new Error("sink is on fire");
|
||||
});
|
||||
setLogLevel("debug");
|
||||
|
||||
createLogger("Player").error("visible anyway");
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatForForwarding", () => {
|
||||
it("renders an Error as name and message, not as {}", () => {
|
||||
// JSON.stringify(new Error("x")) is "{}" -- the single most common way a
|
||||
// log line ends up saying nothing at all.
|
||||
const line = formatForForwarding("[Api]", [new Error("network unreachable")]);
|
||||
expect(line).toContain("Error: network unreachable");
|
||||
});
|
||||
|
||||
it("survives a value that cannot be stringified", () => {
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic.self = cyclic;
|
||||
expect(() => formatForForwarding("[X]", [cyclic])).not.toThrow();
|
||||
expect(formatForForwarding("[X]", [cyclic])).toContain("unserialisable");
|
||||
});
|
||||
|
||||
it("passes strings through untouched", () => {
|
||||
expect(formatForForwarding("[X]", ["plain message"])).toBe("[X] plain message");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,6 +196,110 @@ export function isLevelEnabled(level: LogLevel): boolean {
|
||||
return LEVEL_RANK[level] >= LEVEL_RANK[activeLevel];
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisting a copy: forwarding to the Rust log sink.
|
||||
*
|
||||
* TRACES: UR-078 | DR-204, DR-218
|
||||
*
|
||||
* The console pass-through above is unchanged and stays the primary path — live,
|
||||
* expandable object references in devtools are the whole reason `emit` hands
|
||||
* `console` its arguments untouched. But a console nobody can read is worth
|
||||
* nothing in a bug report, and on Android nobody can read it at all.
|
||||
*
|
||||
* So messages are *also* stringified and handed to `tauri-plugin-log`, which
|
||||
* writes them to the same rotating file the Rust half writes to. One file, one
|
||||
* timeline, both halves of the app in order — which is what makes a race between
|
||||
* them (this project's most expensive bug class) legible after the fact.
|
||||
*
|
||||
* Two deliberate limits:
|
||||
*
|
||||
* - **`info` and above only.** `debug` is per-tick player state; forwarding it
|
||||
* would mean thousands of IPC calls a minute for output nobody reads.
|
||||
* - **Never throws into the caller.** A logging failure must not become an
|
||||
* application failure, so the forward is fire-and-forget with the rejection
|
||||
* swallowed. There is nowhere useful to report a failure to log, anyway.
|
||||
*/
|
||||
export type LogForwarder = (level: LogLevel, message: string) => void;
|
||||
|
||||
/** Levels that cross the IPC boundary. */
|
||||
const FORWARDED_RANK = LEVEL_RANK.info;
|
||||
|
||||
/** Should a message at this level be persisted, as opposed to only shown? */
|
||||
export function shouldForward(level: LogLevel): boolean {
|
||||
return LEVEL_RANK[level] >= FORWARDED_RANK;
|
||||
}
|
||||
|
||||
let forwarder: LogForwarder | null = null;
|
||||
let forwarderLoading = false;
|
||||
|
||||
/**
|
||||
* Replace the sink messages are persisted to.
|
||||
*
|
||||
* Exists for tests, and for any host that wants to capture instead of persist.
|
||||
* Passing `null` restores the default (lazy-loaded plugin) behaviour.
|
||||
*/
|
||||
export function setLogForwarder(next: LogForwarder | null): void {
|
||||
forwarder = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the plugin the first time something needs persisting.
|
||||
*
|
||||
* Lazy because importing it eagerly would pull a Tauri module into every unit
|
||||
* test and into SSR, neither of which has a backend to talk to. The load is
|
||||
* attempted once; if it fails (a browser, a test, a webview without the plugin)
|
||||
* the app keeps logging to the console and never retries.
|
||||
*/
|
||||
function ensureForwarder(): void {
|
||||
if (forwarder || forwarderLoading) return;
|
||||
forwarderLoading = true;
|
||||
|
||||
import("@tauri-apps/plugin-log")
|
||||
.then((plugin) => {
|
||||
forwarder = (level, message) => {
|
||||
// `.catch(swallow)`, never a bare `void`. These return promises, and a
|
||||
// `void promise` discards the *value* while leaving a rejection
|
||||
// unhandled — which in a webview with no IPC (a unit test, SSR, a
|
||||
// browser preview) turns every log line into an unhandled rejection.
|
||||
// A logging failure must stay invisible to the application.
|
||||
const swallow = () => {};
|
||||
switch (level) {
|
||||
case "error":
|
||||
plugin.error(message).catch(swallow);
|
||||
break;
|
||||
case "warn":
|
||||
plugin.warn(message).catch(swallow);
|
||||
break;
|
||||
default:
|
||||
plugin.info(message).catch(swallow);
|
||||
}
|
||||
};
|
||||
})
|
||||
.catch(() => {
|
||||
// No backend here. The console path above still works.
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render arguments to a single line for the persistent log.
|
||||
*
|
||||
* The console gets the live values; the file can only hold text. An object that
|
||||
* cannot be stringified (a cycle, a DOM node) must not break logging, so it
|
||||
* degrades to its type rather than throwing.
|
||||
*/
|
||||
export function formatForForwarding(tag: string, args: unknown[]): string {
|
||||
const rendered = args.map((arg) => {
|
||||
if (typeof arg === "string") return arg;
|
||||
if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
|
||||
try {
|
||||
return JSON.stringify(arg) ?? String(arg);
|
||||
} catch {
|
||||
return `[unserialisable ${typeof arg}]`;
|
||||
}
|
||||
});
|
||||
return `${tag} ${rendered.join(" ")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a logger tagged with `scope`.
|
||||
*
|
||||
@@ -220,6 +324,17 @@ export function createLogger(scope: string): Logger {
|
||||
} else {
|
||||
console[method](tag, ...args);
|
||||
}
|
||||
|
||||
// ...and a stringified copy into the persistent log. After the console call
|
||||
// on purpose: whatever happens here, the developer-facing output has already
|
||||
// happened.
|
||||
if (!shouldForward(level)) return;
|
||||
ensureForwarder();
|
||||
try {
|
||||
forwarder?.(level, formatForForwarding(tag, args));
|
||||
} catch {
|
||||
// A failure to log is not a failure worth propagating.
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user