Files
zapstore/spec/features/FEAT-005-diagnostic-logs.md
2026-04-30 21:42:41 -03:00

126 lines
7.9 KiB
Markdown

# FEAT-005 — Local Diagnostic Logs
## Goal
Capture rich, structured diagnostic logs and uncaught errors to local storage so users can export and share them through any channel of their choosing. Nothing leaves the device automatically.
## Non-Goals
- No remote upload, telemetry, or analytics of any kind.
- No opt-in "send report" path. Sharing is always user-initiated via the OS share sheet.
- No structured query language; a simple text filter is enough.
- No log streaming over ADB or web sockets (developers already have `flutter logs`).
- No replacement for in-app user-facing error UI (toasts, error overlays). Logs are diagnostic, not UX.
## Core Principles
- **Local-only.** Logs never leave the device unless the user explicitly exports and shares them.
- **Always on.** Default level in release builds is `debug`. There is no production "off" switch — logs are how we diagnose field issues.
- **Crash-safe.** Logs written before a crash MUST survive the crash and be readable on next launch.
- **Non-blocking.** Logging MUST NOT block the UI thread. Writes are batched and flushed on a background queue.
- **Secret-safe.** Sensitive values (signing keys, NWC URIs, encrypted-DM plaintext) MUST be redacted at write time, not at export time.
## Capture Surface
All four error sinks below MUST be wired in `main()`, set up before `runApp` is called inside the guarded zone:
| Sink | Source captured |
|------|-----------------|
| `FlutterError.onError` | Sync framework errors (build, layout, paint) |
| `PlatformDispatcher.instance.onError` | Engine and uncaught Dart errors not in a guarded zone |
| `runZonedGuarded` | Async errors originating inside the app's root zone |
| `Isolate.current.addErrorListener` | Uncaught errors from the main isolate that bypass zones |
Background isolates we own — at minimum the `workmanager` `callbackDispatcher` in `lib/services/background_update_service.dart` — MUST install the same four handlers and write to the shared log file.
The framework console dump (`FlutterError.presentError` / `dumpErrorToConsole`) MUST still run in debug builds so devs see errors in `flutter run` output.
## Storage Model
- Path: `getApplicationSupportDirectory()/logs/zapstore.log` with rotation `zapstore.log.1` … `zapstore.log.5`.
- Format: NDJSON, one record per line. Each record contains:
- `ts` — ISO-8601 UTC timestamp
- `level` — `trace` | `debug` | `info` | `warn` | `error` | `fatal`
- `tag` — short logical area (e.g. `package_manager`, `relay`, `signer`)
- `msg` — human-readable message
- `fields` — optional flat map of structured context
- `err` — error string if any
- `stack` — stack trace string if any
- `isolate` — `main` | `workmanager` | other named isolate
- Rotation: when `zapstore.log` exceeds 10 MB, rotate. Keep at most 5 historical files. Total disk budget ≤ 60 MB.
- All isolates write to the **same** active log file using append-mode writes plus an OS advisory file lock (`flock`/`LOCK_EX`) per batch.
- An in-memory **ring buffer of the last 500 entries** is maintained on the main isolate for fast in-app viewing without disk reads.
## User-Visible Behavior
A new "Diagnostics" section in the profile/settings screen exposes:
- **View recent logs** — full-screen scrollable viewer backed by the ring buffer plus the tail of the active log file. Supports:
- Level filter (chips: debug / info / warn / error)
- Free-text filter
- Copy-to-clipboard for any single entry
- **Export logs** — bundles all rotated log files into `zapstore-logs-<UTC-timestamp>.zip` in the cache directory and opens the OS share sheet via `share_plus`. The user picks the destination (Signal, email, Files, etc.).
- **Clear logs** — confirms with a dialog, then deletes all log files and clears the ring buffer.
- **Log level** — selector for `debug` (default) / `info` / `warn`. Persisted in `SettingsService`.
States required:
- Empty (no logs yet) — viewer shows "No logs yet"; Export shows toast "No logs to export" and does not open the share sheet.
- Large export — show file size before opening the share sheet so the user can cancel.
- Disk error during export — toast with the error; do not open share sheet.
## Redaction
At write time, the logger MUST redact:
- Any string matching `nsec1[ac-hj-np-z02-9]+`
- Any string matching `ncryptsec1[ac-hj-np-z02-9]+`
- NWC connection URIs (`nostr+walletconnect://...`)
- Plaintext content of NIP-04 (kind 4), NIP-44 / NIP-17 (kind 13, 1059) events
- Values stored in `flutter_secure_storage`
Redaction replaces the value with `[REDACTED:<kind>]`. Public Nostr event JSON (kinds outside the list above) is **not** redacted — debuggability of public data is preferred.
A unit test MUST assert that none of the above patterns can appear in the log output even if passed as `msg`, `fields`, `err`, or `stack`.
## Riverpod Integration
A `ProviderObserver` MUST be installed on the root `ProviderContainer` to log:
- Provider build failures (`providerDidFail`) at `error` level
- `StorageError` states emitted from `query<T>` providers at `warn` level
This gives automatic coverage of the standard Riverpod async-error path described in `ARCHITECTURE.md`.
## Edge Cases
- **Disk full** — drop the oldest rotated file, then drop new entries; emit one `warn` to stderr per minute max so we don't loop.
- **Corrupted log file** — on read, tolerate malformed lines (skip them); on write, never fail the app.
- **Logs directory missing or read-only** — disable disk logging for the session, keep ring buffer working, surface a single warning entry.
- **Concurrent writes from multiple isolates** — serialised via file lock; entries from different isolates may interleave at line granularity but never at byte granularity.
- **Crash mid-write** — file is append-only with line-flushed writes; the worst case is a truncated final line, which the reader skips.
- **Clock skew / device time wrong** — record `ts` as device clock; do not attempt correction.
- **Export with no logs** — surface "No logs to export"; do not produce an empty zip.
- **Export while writing** — copy/snapshot files into the cache dir before zipping so the share is consistent.
- **Very large `fields` value** — truncate any single string field to 4 KB; replace with `…(truncated)`.
## Acceptance Criteria
- [ ] All four error sinks (`FlutterError.onError`, `PlatformDispatcher.onError`, `runZonedGuarded`, `Isolate.addErrorListener`) are wired in `main()` and route to `LogService`.
- [ ] The `workmanager` `callbackDispatcher` installs the same handlers and writes to the shared log file.
- [ ] Triggering each of: a sync UI exception, an async future error, an uncaught isolate error, and a workmanager task error each produces a `fatal`/`error` entry visible after app restart.
- [ ] Logs persist across app restart and rotate at 10 MB; at most 5 rotations are kept.
- [ ] Logging never blocks the UI thread (verified by a stress test of 1000 entries/sec for 5 s with no dropped frames).
- [ ] No log line ever contains an `nsec1…`, `ncryptsec1…`, `nostr+walletconnect://…`, or kind-4/13/1059 plaintext (verified by unit test).
- [ ] In-app viewer shows ring-buffer + on-disk tail, supports level and text filter, supports copy.
- [ ] Export produces a `zapstore-logs-<ts>.zip` and opens the OS share sheet; nothing is sent automatically.
- [ ] Clear logs removes all files and empties the ring buffer.
- [ ] App boots cleanly when `logs/` is missing, read-only, or contains a corrupted file.
- [ ] Existing 7 `debugPrint` sites are converted to `LogService` calls in the same change.
- [ ] A `ProviderObserver` logs provider failures and `StorageError` states.
## Notes
- Log level for release builds is `debug` by intent — see Goal/Core Principles. There is no network cost and the file is size-capped.
- A future feature may add a one-tap "share with developer" button, but it MUST go through the same user-initiated export flow. No background upload will ever be added under this feature.