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:
@@ -87,6 +87,7 @@ For a narrative overview of the system design, see
|
||||
| UR-075 | Artwork is shown at the shape it was made in. Where a screen presents a set of things side by side — the libraries on the library page and on home — they are laid out as a mosaic: rows of a common height in which each tile is as wide as its own picture, rather than a grid that crops every cover to one box. Favourites are reachable per category from that same mosaic, beside the library they belong to, not only as one undifferentiated list | Medium | Done |
|
||||
| UR-076 | Music browsing shows only what the listener considers music. A Jellyfin server commonly keeps podcasts, audiobooks, sound effects or sample packs in their own folders inside a music library; those folders can be **excluded by choice**, once, and every music surface — library grids, artist and album listings, genre rows, search and the home screen — then agrees on what is in scope. The choice is by folder, not by a name the app happens to recognise, so a folder called anything at all can be excluded and an item is never dropped because its title matched a word | Medium | Done |
|
||||
| UR-077 | The app can update itself, or tell the user how. Somebody who installed an AppImage or ran the Windows installer had no upgrade path at all: nothing in the app ever mentioned that a newer version existed, and the release notes were the only announcement. On Linux and Windows the app checks a signed manifest, offers the new version with its notes, and installs and relaunches on request — the signature check is the point, since it is what stops a substituted download from being installed by the app itself. Android cannot do this (an app may not overwrite its own APK; that is the package installer's job) and is given the honest alternative, a link to the releases page, rather than a button that would throw | Medium | Done |
|
||||
| UR-078 | JellyTau keeps a record of what it did, and can hand it over. The app forgot everything the moment it exited: the backend logged to stdout only — which a user launching from a desktop icon never sees, and which on Android is not logcat, so the Rust half was invisible on the platform carrying the hardest bugs. A crash left nothing at all. Logs are now written to a size-capped rotating file, a panic is recorded before the process dies, the frontend's messages land in the same timeline as the backend's, and Settings exports the lot as one file to attach to a bug report. Nothing is transmitted anywhere — the user attaches it themselves, which is also what keeps this from being telemetry. Access tokens and passwords never reach the file | Medium | Done |
|
||||
| UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done |
|
||||
|
||||
---
|
||||
@@ -408,6 +409,7 @@ Internal architecture, components, and application logic.
|
||||
| DR-215 | Frontend test coverage is a ratcheted CI gate rather than a number nobody looks at. `test:coverage` had been configured since the suite was created and was silently broken: `@vitest/coverage-v8` resolved to 4.1.10, whose peer range pins `vitest` exactly, while `package.json` asked for `>=1.0.0 <5.0.0` and got 4.0.16 — so every invocation died on a missing `BaseCoverageProvider` export and no coverage figure had been produced in months. Fixing the range is half the requirement; the other half is that a measured figure that gates nothing decays the same way an unrun script does. Thresholds sit a few points under the measured result (statements 54.6, branches 48.7, functions 49.6, lines 55.1 when this landed) and only ever move up, matching `MIN_THRESHOLD` in the traceability gate and the eslint `--max-warnings` ratchet. The absolute numbers are held down by `.svelte` components, which this project deliberately does not test directly — the pattern is to extract the logic to a plain module and test that | Tooling | - | Done |
|
||||
| DR-216 | Dependencies are gated on known vulnerabilities and on licence compatibility, and the build graph is pinned to what is actually shipped. The project had no scanning of any kind: nothing checked the ~500-crate Rust graph or the JS packages against an advisory feed, and nothing checked that everything redistributed inside an MIT-licensed bundle permits it. The first run found eight vulnerabilities and one unsoundness — `bytes`, four in `rustls-webpki`, `time`, two in `quick-xml`, `rand` — every one closed by a `cargo update` nobody had reason to run. `cargo deny` (src-tauri/deny.toml) now runs in CI over advisories, licences, bans and sources. Two structural fixes matter as much as the gate: the graph is scoped to the targets actually shipped, so an advisory against an Apple-only path is correctly absent rather than ignored by ID; and the one git dependency (`libmpv`) is pinned by revision instead of by branch, since a branch means any `cargo update` silently substitutes new upstream code in the one dependency that is unsigned and links a C library into the player. Licence findings are recorded rather than waved through — `libmpv`/`libmpv-sys` are LGPL-2.1, which the app satisfies by dynamic linking, and that carries obligations (keep the linkage dynamic; ship libmpv's licence text with any bundle carrying the .so) | Tooling | - | Done |
|
||||
| DR-217 | In-app update, desktop only, over a manifest we control. `tauri-plugin-updater` and `tauri-plugin-process` are compiled for everything except Android/iOS — spelled as a target-triple cfg rather than `cfg(desktop)`, which Cargo does not evaluate in a `[target.'cfg(…)']` table and which therefore drops the dependency silently, surfacing much later as "Permission updater:default not found". The release workflow signs updater artifacts with a minisign key held in Gitea secrets and publishes `latest.json` to a dedicated `updater` branch, read over Gitea's raw-file URL: this instance serves `/releases/download/<tag>/<asset>` but returns 404 for `/releases/latest/download/<asset>`, so there is no stable latest-release URL to point at, and the docs branch is force-pushed by publish-docs.yml so it cannot host the manifest either. Bundle targets gain `appimage`, which the release notes had been advertising for months while `tauri.conf.json` never built it — the artifact step globbed for `*.AppImage`, found nothing, and said nothing | Tooling | UR-077 | Done |
|
||||
| DR-218 | Persistent, redacted logging and a diagnostics export. `tauri-plugin-log` replaces the `env_logger` stdout-only init, giving a rotating 5 MB file, a webview target in dev, and — the single largest gain — logcat on Android, where `env_logger`'s stdout went nowhere. **Redaction runs in the log formatter, not at export**: a credential in a file on the device is already a disclosure, so stripping it on the way out would be too late; the exporter redacts a second time to cover files written by older builds. `api_key`/`X-Emby-Token`/`Authorization`/`"AccessToken"`/`Token="…"` all reduce to `[REDACTED]` while host, item ids and filenames are deliberately kept — a bundle scrubbed of those is one nobody can debug from. The server URL is reduced to scheme and host, dropping any embedded `user:pass@`. 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. The chosen level persists to disk and is re-applied at startup, since reproducing a bug usually means restarting into it. The frontend facade keeps its untouched `console.*` pass-through (DR-204) and additionally forwards a stringified copy at info and above, so one file holds both halves of the app in order — which is what makes a race between them legible after the fact | Tooling | UR-078 | Done |
|
||||
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
|
||||
|
||||
---
|
||||
@@ -494,6 +496,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-075 | - | DR-174, DR-175 |
|
||||
| UR-076 | - | DR-209 |
|
||||
| UR-077 | - | DR-217 |
|
||||
| UR-078 | - | DR-218 |
|
||||
|
||||
---
|
||||
|
||||
@@ -705,6 +708,7 @@ Internal architecture, components, and application logic.
|
||||
| UT-200 | The stream a player could only restart is refused its retry: the handoff transcode answers yes to `player_retry_restarts_stream` while music, video and a downloaded episode answer no, and the Kotlin decision starts permissive, flips on a non-resumable load, and is restored by the next ordinary one | DR-203 | Done |
|
||||
| UT-207 | The hero banner's rotation timer restarts from the moment of a manual change: a swipe 5.5s into a 6s interval waits a further 6s instead of firing the leftover 500ms, repeated restarts never stack timers, and `stop()` ends rotation | DR-038 | Done |
|
||||
| UT-208 | The update decision: each numeric version field is compared in order, the installed version is not offered to itself, a leading `v` is tolerated because that is how the tags are written, a pre-release sorts below the release of the same number so 0.9.2-rc1 is not offered to somebody on 0.9.2, a missing patch field reads as zero rather than NaN, mobile reports link-only while desktop reports install, and absent release notes normalise to null rather than undefined | DR-217 | Done |
|
||||
| UT-209 | Redaction and forwarding. Rust: every credential shape reduces to `[REDACTED]` while the host, username and neighbouring parameters survive; redaction is idempotent, leaves ordinary lines alone, does not fire on the word "token" in prose, and does not panic on multi-byte input; a server URL keeps only scheme and host and drops an embedded `user:pass@`; an unparseable level falls back to info rather than failing at startup. Frontend: info and above forward while debug does not, a message the level filter suppressed is not forwarded, a throwing forwarder neither propagates nor prevents the console write, and an `Error` renders as name and message rather than the `{}` that `JSON.stringify` produces | DR-218 | Done |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# Spec: Diagnostics and persistent logging
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** UR-078 → DR-218; tests UT-209
|
||||
**UX spec:** n/a (one Settings section; no new flow)
|
||||
**Destination on completion:** [09-security.md](../architecture/09-security.md)
|
||||
for the redaction rules, and a new "Logging and diagnostics" section in
|
||||
[01-rust-backend.md](../architecture/01-rust-backend.md) for the capture path.
|
||||
|
||||
## Summary
|
||||
|
||||
JellyTau records what it does, keeps it in a size-capped file on disk, survives a
|
||||
crash, and can hand the whole thing to the user as one file to attach to a bug
|
||||
report. Credentials never reach that file.
|
||||
|
||||
## Motivation
|
||||
|
||||
Today the app forgets everything the moment it exits.
|
||||
|
||||
The Rust half logs through `env_logger` to **stdout only**. A user who launched
|
||||
from a desktop icon has no stdout. On Android it is worse than useless:
|
||||
`env_logger` writes to stdout, which is not logcat, so **the Rust backend's
|
||||
output is invisible on the platform where most of the hard bugs have been** — the
|
||||
autoplay deadlock, the truncated-stream restart, the background-audio stall. The
|
||||
frontend has a proper leveled facade (`logger.ts`, DR-204) but it only reaches
|
||||
the webview console, which nobody can read on a phone.
|
||||
|
||||
The practical consequence is visible in this project's history: several bugs took
|
||||
multiple rounds of "can you reproduce it under `adb logcat`" before anyone could
|
||||
even see what happened. A user reporting "the episode randomly restarted" is
|
||||
reporting the symptom of a race whose evidence was discarded microseconds later.
|
||||
|
||||
There is also no crash record at all. If the app panics, the user sees it vanish
|
||||
and we learn nothing.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|---|---|---|
|
||||
| What is captured, at what level, and where it is written | Rust | Retention and capture policy is backend behaviour; it must work identically whether the UI is open, backgrounded, or gone |
|
||||
| Log rotation and the size cap | Rust | Storage management, same class as the download and image caches |
|
||||
| **Redaction of credentials** | Rust | Security-critical, and the values (tokens, `api_key`, keyring payloads) are domain vocabulary owned by the auth layer. A frontend that redacted its own messages would still not cover anything Rust wrote |
|
||||
| Panic capture and persistence | Rust | Only Rust can install a panic hook |
|
||||
| Assembling the export (archive + environment summary) | Rust | Touches the filesystem and the app's own paths; also the last point at which redaction can be enforced over everything |
|
||||
| Which log level is active | Rust owns the *stored* setting and applies it; the frontend renders the picker | Same split as every other setting: the value is state the backend acts on, the control is presentation |
|
||||
| Showing the export path / opening the folder | Frontend | Pure presentation |
|
||||
| Formatting a log line for the webview console | Frontend | `logger.ts` already owns this; unchanged |
|
||||
|
||||
Borderline: **the frontend forwarding its own messages into the Rust sink.**
|
||||
Arguably presentation "sending data down". Placed as: the frontend calls a
|
||||
plugin, and the *decision of what to persist and how to redact it* stays in Rust
|
||||
— which is the tie-breaker, because a bug report containing a token would be a
|
||||
security defect regardless of which half wrote the line.
|
||||
|
||||
## Design
|
||||
|
||||
### Capture
|
||||
|
||||
Replace the `env_logger` init in `lib.rs` with `tauri-plugin-log`, which is the
|
||||
official plugin and already does the three things we would otherwise hand-roll
|
||||
(CLAUDE.md: prefer official plugins before writing native code):
|
||||
|
||||
| Target | Purpose |
|
||||
|---|---|
|
||||
| `Stdout` | unchanged behaviour for `bun run tauri dev` |
|
||||
| `LogDir { file_name: "jellytau" }` | the persistent, rotating file |
|
||||
| `Webview` (dev only) | Rust lines visible in the webview console while developing |
|
||||
|
||||
On Android the plugin routes to **logcat**, which is the single largest
|
||||
improvement here and needs no code of ours.
|
||||
|
||||
Rotation: `RotationStrategy::KeepAll` is wrong for a phone. Use a size cap
|
||||
(5 MB) with one retained previous file, so a long session cannot fill a device
|
||||
and yesterday's evidence still exists.
|
||||
|
||||
Level: default `Info`, `RUST_LOG` still honoured, and a stored user preference
|
||||
that survives restart (a user reproducing a bug needs debug logging *across* the
|
||||
restart that reproduces it).
|
||||
|
||||
### Redaction
|
||||
|
||||
A pure function in a new `src-tauri/src/utils/diagnostics.rs`:
|
||||
|
||||
```rust
|
||||
pub fn redact(line: &str) -> String
|
||||
```
|
||||
|
||||
It replaces the value in each of these with `[REDACTED]`, case-insensitively:
|
||||
|
||||
- `api_key=…` and `ApiKey=…` in URLs and query strings
|
||||
- `X-Emby-Token: …`, `X-MediaBrowser-Token: …`, `Authorization: …` headers
|
||||
- `"AccessToken":"…"` in JSON bodies
|
||||
- `MediaBrowser Token="…"` in the Emby auth header form
|
||||
|
||||
What it deliberately does **not** remove: the server host, item ids, and
|
||||
filenames. Those are what make a log useful, they are not secrets, and stripping
|
||||
them would produce a diagnostic bundle nobody can diagnose anything from.
|
||||
|
||||
Applied at two points: on every line the export copies, and — because the export
|
||||
is not the only way a file leaves a device — inside the log formatter itself, so
|
||||
the token never reaches disk in the first place. The export-time pass exists to
|
||||
cover files written before an upgrade.
|
||||
|
||||
### Export
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub async fn diagnostics_export(app: AppHandle) -> Result<DiagnosticsBundle, String>
|
||||
```
|
||||
|
||||
Writes a single `.zip` and returns where it went:
|
||||
|
||||
```rust
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DiagnosticsBundle {
|
||||
pub path: String,
|
||||
pub size_bytes: u64,
|
||||
pub file_count: usize,
|
||||
}
|
||||
```
|
||||
|
||||
Contents: the current and previous log files (redacted), plus `environment.txt`
|
||||
— app version, OS and arch, whether the build is debug, the active log level, and
|
||||
the *scheme and host* of the configured server. No token, no username, no path
|
||||
inside the user's home beyond the app's own directories.
|
||||
|
||||
### Frontend
|
||||
|
||||
`logger.ts` keeps its `console.*` pass-through untouched — live object references
|
||||
in devtools are a stated design goal of DR-204 — and *additionally* forwards a
|
||||
stringified copy at `info` and above to the plugin, so one timeline contains both
|
||||
halves of the app. Forwarding is fire-and-forget and never throws into a caller:
|
||||
a logging failure must not become an application failure.
|
||||
|
||||
A `Diagnostics` section in Settings shows the log location, a level picker, and
|
||||
an **Export diagnostics** button that reports the resulting path and, on desktop,
|
||||
offers to reveal it.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **An Android share sheet.** Export writes to the app's files directory and
|
||||
reports the path; wiring a native `ACTION_SEND` intent is a Kotlin change that
|
||||
belongs with the other native work, not here.
|
||||
- **Uploading anywhere.** Nothing is transmitted. The user attaches the file
|
||||
themselves, which is also what keeps this from becoming telemetry.
|
||||
- **Frontend `debug` forwarding.** Only `info`+ crosses the IPC boundary; per-tick
|
||||
player debug would be thousands of calls a minute.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Rust logs reach a rotating file on Linux and **logcat** on Android.
|
||||
- [ ] A panic is recorded and is present in the next export.
|
||||
- [ ] Frontend `info`/`warn`/`error` appear in the same file as Rust's lines.
|
||||
- [ ] An export containing a request URL with `api_key=` shows `[REDACTED]`, and
|
||||
a test greps the produced bundle for the token to prove it.
|
||||
- [ ] The log file cannot exceed the cap.
|
||||
- [ ] `bun run check`, `bun run test`, `cargo fmt`, `cargo clippy -D warnings`,
|
||||
`bun run test:rust`, `bun run check:boundary` all pass.
|
||||
- [ ] `bindings.ts` regenerated (new command and struct).
|
||||
- [ ] New code carries `TRACES:` comments.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust** (`cargo test`): `redact` over each credential shape, including one
|
||||
already-redacted line (idempotent) and a line containing no secret (unchanged);
|
||||
that the environment summary contains a host but no token; that rotation respects
|
||||
the cap.
|
||||
|
||||
**Frontend** (`vitest`): that the forwarder is called for `info`+ and not for
|
||||
`debug`; that a rejected forward does not propagate to the caller.
|
||||
|
||||
## TRACES
|
||||
|
||||
| Piece | Tag |
|
||||
|---|---|
|
||||
| `utils/diagnostics.rs` | `UR-078 \| DR-218` |
|
||||
| `commands/diagnostics.rs` | `UR-078 \| DR-218` |
|
||||
| logging init in `lib.rs` | `UR-078 \| DR-218` |
|
||||
| `logger.ts` forwarding | `UR-078 \| DR-204, DR-218` |
|
||||
| Settings section | `UR-078 \| DR-218` |
|
||||
| tests | `\| DR-218 \| UT-209` |
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- A parallel Claude session may be active — `git diff` before "repairing"
|
||||
anything unexpected.
|
||||
- `utils/lock.rs` already sets and restores a panic hook in its tests. The
|
||||
diagnostics hook must chain to the previous hook rather than replace it, or
|
||||
those tests start reporting panics they deliberately suppress.
|
||||
- Do not call the exporter from an event callback that can re-enter the player:
|
||||
it does blocking file I/O (see the deadlock note in CLAUDE.md).
|
||||
Reference in New Issue
Block a user