Files
zapstore/spec/work/WORK-008-diagnostic-logs.md
T
2026-04-30 21:42:41 -03:00

8.8 KiB
Raw Blame History

WORK-008 — Local Diagnostic Logs

Feature: FEAT-005-diagnostic-logs.md Status: Complete

Tasks

  • 1. Add LogService (singleton, isolate-safe)

    • Files: lib/services/log_service.dart
    • NDJSON append writer with batched async flush, advisory file lock per batch (FileLock.blockingExclusive)
    • Levels: trace/debug/info/warn/error/fatal
    • Ring buffer of last 500 entries (kLogRingBufferSize)
    • Path: getApplicationSupportDirectory()/logs/zapstore.log + .1–.5
    • Rotation at 10 MB; max 5 historical files
    • init({required String isolateName}) and LogService.forTesting(...) for use from any isolate or test
    • flushSync() for crash paths
    • Smoke tested in test/services/log_service_test.dart (14 tests passing)
  • 2. Implement redactor

    • Files: lib/services/log_service.dart (LogRedactor)
    • Patterns: nsec1…, ncryptsec1…, nostr+walletconnect://… — applied at write time to msg, fields (recursive), err, stack
    • LogRedactor.redactPlaintext(kind: ...) helper for call sites with kind-4/13/1059 plaintext
    • Truncates any single string to kLogMaxFieldBytes (4 KB) with …(truncated) marker
    • Tested: nsec / ncryptsec / NWC / nested fields / oversized strings
  • 3. Wire all four error sinks in main()

    • Files: lib/main.dart
    • _installErrorHandlers() sets all four BEFORE runApp
    • FlutterError.onError (calls presentError for console)
    • PlatformDispatcher.instance.onError
    • runZonedGuarded wraps runApp
    • Isolate.current.addErrorListener via RawReceivePort (held in top-level _isolateErrorPort so it isn't GC'd)
    • All sinks route through _logUncaught which calls fatal() then flushSync()
  • 4. Wire handlers in workmanager callbackDispatcher

    • Files: lib/services/background_update_service.dart
    • LogService.init(isolateName: 'workmanager') at start of dispatcher
    • Same four handlers, scoped to the isolate; entries tagged isolate=workmanager
    • flushSync() after each fatal and flush() in task finally
  • 5. Riverpod ProviderObserver

    • Files: lib/services/log_service.dart (LoggingProviderObserver), lib/main.dart
    • providerDidFail → error
    • didUpdateProvider with new value is StorageError → warn
    • Attached to the root ProviderContainer via observers: [LoggingProviderObserver()]
  • 6. Convert existing debugPrint sites

    • Files: lib/main.dart, lib/services/package_manager/android_package_manager.dart, lib/services/updates_service.dart, lib/services/package_manager/installed_packages_snapshot.dart, lib/services/package_manager/package_manager.dart, lib/services/deep_link_resolver.dart, lib/services/package_manager/device_capabilities.dart
    • All 23 debugPrint sites replaced with LogService.I.<level>(...) with structured fields
    • Removed now-unused flutter/foundation imports
  • 7. Audit silent catch (_) {} blocks flagged by INVARIANTS

    • _maybeCopySeedDatabase and _attemptAutoSignIn now log warn/debug respectively
    • Other silent catches are intentional cleanup paths (cancelling already-cancelled tasks, deleting temp files, the LogService swallowing its own write errors) — left as-is
  • 8. Settings: log level setting

    • LocalSettings.logLevel: LogLevel added (default debug); JSON-persisted only if non-default
    • Applied in appInitializationProvider after settings load
    • Diagnostics screen has a level dropdown that updates settings + applies live
  • 9. Diagnostics screen

    • Files: lib/screens/diagnostics_screen.dart, lib/router.dart (/profile/diagnostics), lib/screens/profile_screen.dart (Data Management entry)
    • Viewer merges ring buffer + disk tail (1000 lines), filter chips (All/Debug+/Info+/Warn+/Error+), free-text filter, copy-to-clipboard per entry
    • Export: snapshots all rotation files into cache dir, zips via archive package, shares via share_plus with XFile
    • Confirm-then-clear with explicit dialog; empty state explicit
    • HookConsumerWidget per INVARIANTS
  • 10. Tests

    • test/services/log_service_test.dart — 17 tests, all passing
      • Basics: ring buffer, level filter, bounded ring, readTail, clear
      • Redaction: nsec / ncryptsec / NWC across msg, nested fields, err, stack; >4 KB truncation
      • Rotation: triggers at threshold; rotation cap enforced
      • Resilience: corrupted line skipped; flushSync writes pending entries
      • Crash sinks (contract): all four sources tagged correctly and durable after flushSync
      • Stress: 1000 entries non-blocking; 1000 lines reach disk
      • Concurrency: 50 overlapping flush() callers — every line on disk fully formed (caught and fixed a real race in flush() / _runFlush coordination)
      • Codec: LogEntry JSON round-trip; malformed input rejected
  • 11. Self-review against INVARIANTS.md

    • UI never blocks on I/O — log() is synchronous to caller, all writes async on microtask
    • No polling / artificial delays — flush is event-driven; _lastDiskFullWarn only rate-limits stderr noise
    • Lifecycle: _isolateErrorPort and _workmanagerErrorPort held at top-level so listeners aren't GC'd; flush() called on AppLifecycleState.paused, flushSync() on detached
    • Secrets: LogRedactor strips nsec / ncryptsec / NWC URIs at write time, recursively across all field shapes; tested
    • No silent failures: ad-hoc catch (_) {} audited; logger itself swallows on purpose (logging cannot crash the app)
    • Hooks: DiagnosticsScreen is HookConsumerWidget, no StatefulWidget
    • Reproducible builds: no build-config changes; archive promoted from transitive to direct, no nondeterministic plugins added

Test Coverage

Scenario Expected Status
All four error sinks (contract) fatal entry tagged with source, durable after flushSync [x] unit
1000 entries non-blocking log() returns in <500 ms total; all 1000 reach disk [x] unit
50 overlapping flush() callers Every line on disk fully formed JSON [x] unit
Log file reaches 10 MB Rotates to .1 [x] unit
6th rotation Oldest file deleted, cap enforced [x] unit
nsec1… / NWC URI / ncryptsec1… in msg / fields (nested) / err / stack Replaced with [REDACTED:*] [x] unit
>4 KB field string Truncated with …(truncated) [x] unit
Corrupted log line Reader skips it; subsequent reads keep working [x] unit
LogEntry JSON round-trip Lossless [x] unit
Workmanager task throws error entry tagged isolate=workmanager, persists across restart [ ] manual
Logs directory read-only Ring buffer still works, single warn surfaced [ ] manual
Export with no logs Toast "No logs to export"; share sheet not opened [ ] manual
Export with logs .zip produced; share sheet opens [ ] manual
Clear logs All files deleted, ring buffer empty [x] unit + [ ] manual UI
StorageError from a query<T> warn entry with provider name [ ] manual
App restart after paused lifecycle Logs readable; entries from before pause survive [ ] manual

Decisions

2026-04-28 — Default log level in release is debug

Context: Standard practice is info or higher in release. We have no network cost, no analytics, and a hard size cap. Options: A) info default, B) debug default, C) per-build flag. Decision: B — debug. Rationale: Field bugs are the whole reason this exists. The 60 MB ceiling and rotation guarantee bounded disk use. Users can lower the level in Diagnostics.

2026-04-28 — Single shared log file across isolates

Context: Workmanager and (future) purplebase background isolates need to log too. Options: A) Per-isolate files merged at read, B) Single file with file lock. Decision: B — single file with flock per batch. Rationale: Simpler reader, simpler export, simpler in-app viewer. Lock contention is low because writes are batched.

2026-04-28 — Redact only secrets, not public Nostr content

Context: Public Nostr events are useful when debugging relay/parse issues; redacting them removes most of the diagnostic value. Options: A) Redact all event JSON, B) Redact only kinds 4/13/1059 + nsec/NWC/secure-storage values. Decision: B. Rationale: Matches "only secrets" intent; preserves debuggability of public data.

2026-04-28 — Export bundles a zip via share_plus

Context: Multiple rotated files; users want a single attachment. Options: A) Share latest file only, B) Zip all rotations. Decision: B. Rationale: One artifact, smaller transfer, complete history.

Spec Issues

None

Progress Notes

None yet

On Merge

Delete this work packet. Promote any non-obvious decisions above to spec/knowledge/DEC-XXX-*.md (likely candidates: shared-log-file decision, default-debug-in-release decision).