Implement diagnostics

This commit is contained in:
franzap
2026-04-30 21:42:41 -03:00
parent 03f7dfdd4a
commit bd396d17f5
19 changed files with 2258 additions and 111 deletions
+2
View File
@@ -58,3 +58,5 @@ reference/*
.fvm/
bugs/
.cursor/skills
+136 -25
View File
@@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:io' show File, Platform;
import 'dart:isolate';
import 'dart:ui' show PlatformDispatcher;
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
@@ -13,6 +15,7 @@ import 'package:purplebase/purplebase.dart';
import 'package:amber_signer/amber_signer.dart';
import 'package:zapstore/services/app_restart_service.dart';
import 'package:zapstore/services/background_update_service.dart';
import 'package:zapstore/services/log_service.dart';
import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/services/settings_service.dart';
import 'package:zapstore/router.dart';
@@ -24,11 +27,44 @@ import 'package:zapstore/services/deep_link_service.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/breathing_logo.dart';
/// Global provider container for error reporting (accessible outside widget tree)
/// Global provider container so handlers outside the widget tree can
/// reach Riverpod state.
late final ProviderContainer _providerContainer;
/// Receives uncaught isolate errors from the main isolate. Held at
/// top level so it survives for the lifetime of the app — without
/// this reference the port could be garbage-collected and the
/// isolate listener would silently stop firing.
// ignore: unused_element
RawReceivePort? _isolateErrorPort;
void main() {
// Create provider container with overrides
// Everything that touches the Flutter binding or schedules async work
// for the app MUST run inside the same zone as `runApp`. Otherwise the
// engine throws a "Zone mismatch" assertion because zone-specific
// configuration (error handlers, microtask hooks) would be split
// between the root zone and the guarded zone.
runZonedGuarded(() {
WidgetsFlutterBinding.ensureInitialized();
// Install all four error sinks BEFORE runApp.
// LogService.I works pre-init (writes go to the ring buffer until
// disk is ready); init() is awaited just below to enable disk.
_installErrorHandlers();
// Bring up disk logging. Not awaited — if path_provider fails the
// LogService falls back to ring-buffer-only and we continue.
unawaited(LogService.I.init(isolateName: 'main').then((_) {
LogService.I.info(
'app starting',
tag: 'app',
fields: {
'platform': Platform.operatingSystem,
'version': Platform.operatingSystemVersion,
},
);
}));
_providerContainer = ProviderContainer(
overrides: [
storageNotifierProvider.overrideWith(PurplebaseStorageNotifier.new),
@@ -38,33 +74,81 @@ void main() {
: DummyPackageManager(ref),
),
],
observers: const [LoggingProviderObserver()],
);
runZonedGuarded(() {
runApp(
UncontrolledProviderScope(
container: _providerContainer,
child: const ZapstoreApp(),
),
);
}, _errorHandler);
FlutterError.onError = (details) {
// Prevents debugger stopping multiple times
FlutterError.dumpErrorToConsole(details);
_errorHandler(details.exception, details.stack);
};
}, (error, stack) => _logUncaught(error, stack, source: 'zone'));
}
/// Global error handler that reports errors via NIP-44 encrypted DMs
void _errorHandler(Object exception, StackTrace? stack) {
// Report error asynchronously (fire and forget)
// TODO: Disabled until careful review
// unawaited(
// _providerContainer
// .read(errorReportingServiceProvider)
// .reportError(exception, stack),
// );
/// Wires the four error sinks documented in FEAT-005:
/// * `FlutterError.onError` — sync framework errors
/// * `PlatformDispatcher.instance.onError` — engine / uncaught Dart
/// * `runZonedGuarded` — async errors in the root zone
/// * `Isolate.current.addErrorListener` — main-isolate errors that
/// bypass zones
///
/// In debug builds errors continue to be dumped to the console via
/// `FlutterError.presentError` so `flutter run` still shows them.
void _installErrorHandlers() {
FlutterError.onError = (details) {
FlutterError.presentError(details);
_logUncaught(details.exception, details.stack,
source: 'flutter', library: details.library);
};
PlatformDispatcher.instance.onError = (error, stack) {
_logUncaught(error, stack, source: 'platform_dispatcher');
// Returning true tells the engine the error is handled (we logged
// it). Returning false would cause the engine to terminate the
// isolate on some platforms.
return true;
};
// Errors that escape to the isolate level (e.g. unhandled errors in
// a Future created in a foreign zone) come back via this port.
final port = RawReceivePort((dynamic pair) {
// Isolate sends a [errorString, stackString] list.
if (pair is List && pair.length == 2) {
final err = pair[0]?.toString() ?? 'unknown';
final stack = pair[1] == null
? null
: StackTrace.fromString(pair[1].toString());
_logUncaught(err, stack, source: 'isolate');
}
});
Isolate.current.addErrorListener(port.sendPort);
_isolateErrorPort = port;
}
/// Common entry point for all four sinks. Always non-blocking. On a
/// fatal sink (Flutter / PlatformDispatcher) we also flush the log
/// synchronously so the entry survives an immediate crash.
void _logUncaught(
Object error,
StackTrace? stack, {
required String source,
String? library,
}) {
LogService.I.fatal(
'uncaught error',
tag: 'crash',
fields: {
'source': source,
if (library != null) 'library': library,
},
err: error,
stack: stack,
);
// Best-effort durable flush. Swallows its own errors.
try {
LogService.I.flushSync();
} catch (_) {}
}
class ZapstoreApp extends HookConsumerWidget {
@@ -122,8 +206,13 @@ class ZapstoreApp extends HookConsumerWidget {
if (toastContext != null && toastContext.mounted) {
toastContext.showInfo('Amber was removed, you were signed out');
}
} catch (error) {
debugPrint('Auto sign-out after Amber uninstall failed: $error');
} catch (error, stack) {
LogService.I.warn(
'auto sign-out after Amber uninstall failed',
tag: 'amber',
err: error,
stack: stack,
);
}
}());
},
@@ -245,6 +334,9 @@ final appInitializationProvider = FutureProvider<void>((ref) async {
final settings = await ref.read(settingsServiceProvider).load();
final appCatalogRelays = settings.appCatalogRelays ?? {_kDefaultAppCatalogRelay};
// Apply persisted log level (default is `debug`).
LogService.I.level = settings.logLevel;
// Initialize storage with local relay config
await ref.read(
initializationProvider(
@@ -320,8 +412,14 @@ Future<void> _maybeCopySeedDatabase(String dbPath) async {
),
flush: true,
);
} catch (_) {
// Non-fatal: the app works fine without the seed — just a cold start
} catch (e, st) {
// Non-fatal: the app works fine without the seed — just a cold start.
LogService.I.warn(
'seed database copy failed',
tag: 'init',
err: e,
stack: st,
);
}
}
@@ -329,8 +427,15 @@ Future<void> _attemptAutoSignIn(Ref ref) async {
try {
await ref.read(amberSignerProvider).attemptAutoSignIn();
await onSignInSuccess(ref);
} catch (e) {
// Auto sign-in fails on first install — that's fine, just continue
} catch (e, st) {
// Auto sign-in fails on first install — that's fine, just continue.
// Logged at debug because this is expected for new users.
LogService.I.debug(
'auto sign-in attempt failed',
tag: 'amber',
err: e,
stack: st,
);
}
}
@@ -397,6 +502,12 @@ class _AppLifecycleObserver with WidgetsBindingObserver {
notifier.connect();
} else if (state == AppLifecycleState.paused) {
notifier.disconnect();
// Flush any pending log entries to disk before the OS may freeze
// or kill us, so diagnostics survive backgrounding.
unawaited(LogService.I.flush());
} else if (state == AppLifecycleState.detached) {
// Last chance before the engine tears down — sync flush.
LogService.I.flushSync();
}
}
+8
View File
@@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/screens/diagnostics_screen.dart';
import 'package:zapstore/screens/main_scaffold.dart';
import 'package:zapstore/screens/app_detail_screen.dart';
import 'package:zapstore/screens/app_stacks_screen.dart';
@@ -175,6 +176,13 @@ final routerProvider = Provider<GoRouter>((ref) {
_stackDetailRoute(),
_allStacksRoute(),
_userRoute(),
GoRoute(
path: 'diagnostics',
pageBuilder: (context, state) => _noTransitionPage(
state: state,
child: const DiagnosticsScreen(),
),
),
],
),
],
+470
View File
@@ -0,0 +1,470 @@
import 'dart:async';
import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:zapstore/services/log_service.dart';
import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/services/settings_service.dart';
/// Full-screen diagnostics view: viewer, export, clear, level selector.
///
/// Backed entirely by [LogService] — no network access, no opt-in
/// telemetry. Export uses the OS share sheet via `share_plus`.
class DiagnosticsScreen extends HookConsumerWidget {
const DiagnosticsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedLevel = useState<LogLevel?>(null);
final filterText = useState<String>('');
final tickRefresh = useState(0);
// Re-read disk tail every time the screen is opened so the user
// sees recent entries that may have been flushed asynchronously.
final tailFuture = useMemoized<Future<List<LogEntry>>>(
() => LogService.I.readTail(max: 1000),
[tickRefresh.value],
);
final tailSnapshot = useFuture(tailFuture, initialData: const <LogEntry>[]);
final ringEntries = LogService.I.ringSnapshot();
final entries = _mergeEntries(ringEntries, tailSnapshot.data ?? const []);
final filtered = _filterEntries(
entries,
level: selectedLevel.value,
query: filterText.value,
);
return Scaffold(
appBar: AppBar(title: const Text('Diagnostics')),
body: Column(
children: [
_LogLevelControl(),
const Divider(height: 1),
_ToolbarRow(
onExport: () => _exportLogs(context),
onClear: () => _confirmAndClear(context, () {
tickRefresh.value++;
}),
onRefresh: () => tickRefresh.value++,
entryCount: filtered.length,
),
const Divider(height: 1),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Expanded(
child: TextField(
onChanged: (v) => filterText.value = v,
decoration: const InputDecoration(
hintText: 'Filter…',
prefixIcon: Icon(Icons.search),
isDense: true,
border: OutlineInputBorder(),
),
),
),
],
),
),
_LevelChips(
selected: selectedLevel.value,
onSelected: (l) => selectedLevel.value = l,
),
const Divider(height: 1),
Expanded(
child: filtered.isEmpty
? const _EmptyState()
: _LogList(entries: filtered),
),
],
),
);
}
// ---------------------------------------------------------------------------
// Logic
// ---------------------------------------------------------------------------
static List<LogEntry> _mergeEntries(
List<LogEntry> ring,
List<LogEntry> disk,
) {
// Disk has older history, ring has newest in-memory. Merge by
// timestamp + isolate + msg fingerprint to suppress exact duplicates.
final seen = <String>{};
final merged = <LogEntry>[];
for (final e in [...disk, ...ring]) {
final key =
'${e.ts.microsecondsSinceEpoch}|${e.isolate}|${e.level}|${e.tag}|${e.msg}';
if (seen.add(key)) merged.add(e);
}
merged.sort((a, b) => a.ts.compareTo(b.ts));
return merged;
}
static List<LogEntry> _filterEntries(
List<LogEntry> entries, {
LogLevel? level,
String? query,
}) {
final q = (query ?? '').trim().toLowerCase();
return entries.where((e) {
if (level != null && e.level.index < level.index) return false;
if (q.isEmpty) return true;
return e.msg.toLowerCase().contains(q) ||
e.tag.toLowerCase().contains(q) ||
(e.err?.toLowerCase().contains(q) ?? false) ||
(e.fields?.toString().toLowerCase().contains(q) ?? false);
}).toList(growable: false);
}
Future<void> _exportLogs(BuildContext context) async {
final files = LogService.I.currentFiles();
if (files.isEmpty) {
if (context.mounted) {
context.showInfo('No logs to export');
}
return;
}
try {
// Snapshot files into the cache dir before zipping so the export
// is consistent even if writes continue in the background.
final cacheDir = await getApplicationCacheDirectory();
final stamp = DateTime.now()
.toUtc()
.toIso8601String()
.replaceAll(':', '-')
.replaceAll('.', '-');
final outDir = Directory(p.join(cacheDir.path, 'log_exports'));
if (!outDir.existsSync()) outDir.createSync(recursive: true);
final zipPath = p.join(outDir.path, 'zapstore-logs-$stamp.zip');
final encoder = ZipFileEncoder();
encoder.create(zipPath);
for (final f in files) {
if (!f.existsSync()) continue;
await encoder.addFile(f, p.basename(f.path));
}
await encoder.close();
if (!context.mounted) return;
final size = File(zipPath).lengthSync();
// Surface size before sharing so the user can cancel a large transfer.
context.showInfo('Exported ${_humanBytes(size)}');
await SharePlus.instance.share(ShareParams(
files: [XFile(zipPath, mimeType: 'application/zip')],
subject: 'Zapstore diagnostic logs',
text:
'Zapstore diagnostic logs (local export, no telemetry).',
));
} catch (e, st) {
LogService.I.error(
'log export failed',
tag: 'diagnostics',
err: e,
stack: st,
);
if (context.mounted) {
context.showError('Failed to export logs', technicalDetails: '$e');
}
}
}
Future<void> _confirmAndClear(
BuildContext context,
VoidCallback onCleared,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Clear logs?'),
content: const Text(
'This deletes all local diagnostic logs from this device. '
'Already-exported files are not affected.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Clear'),
),
],
),
);
if (confirmed != true) return;
await LogService.I.clear();
onCleared();
if (context.mounted) context.showInfo('Logs cleared');
}
static String _humanBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}
// =============================================================================
// Sub-widgets
// =============================================================================
class _LogLevelControl extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final settingsAsync = ref.watch(localSettingsProvider);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
const Icon(Icons.tune, size: 18),
const SizedBox(width: 8),
const Text('Log level'),
const Spacer(),
settingsAsync.when(
data: (settings) => DropdownButton<LogLevel>(
value: settings.logLevel,
underline: const SizedBox.shrink(),
items: const [
DropdownMenuItem(
value: LogLevel.debug, child: Text('Debug (verbose)')),
DropdownMenuItem(value: LogLevel.info, child: Text('Info')),
DropdownMenuItem(value: LogLevel.warn, child: Text('Warn')),
],
onChanged: (level) async {
if (level == null) return;
await ref.read(settingsServiceProvider).update(
(s) => s.copyWith(logLevel: level),
);
LogService.I.level = level;
ref.invalidate(localSettingsProvider);
},
),
loading: () => const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
error: (_, __) => const Text('—'),
),
],
),
);
}
}
class _ToolbarRow extends StatelessWidget {
const _ToolbarRow({
required this.onExport,
required this.onClear,
required this.onRefresh,
required this.entryCount,
});
final VoidCallback onExport;
final VoidCallback onClear;
final VoidCallback onRefresh;
final int entryCount;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
IconButton(
tooltip: 'Refresh',
onPressed: onRefresh,
icon: const Icon(Icons.refresh),
),
IconButton(
tooltip: 'Export logs',
onPressed: onExport,
icon: const Icon(Icons.ios_share),
),
IconButton(
tooltip: 'Clear logs',
onPressed: onClear,
icon: const Icon(Icons.delete_outline),
),
const Spacer(),
Text('$entryCount entries',
style: Theme.of(context).textTheme.labelSmall),
const SizedBox(width: 8),
],
),
);
}
}
class _LevelChips extends StatelessWidget {
const _LevelChips({required this.selected, required this.onSelected});
final LogLevel? selected;
final ValueChanged<LogLevel?> onSelected;
@override
Widget build(BuildContext context) {
Widget chip(String label, LogLevel? value) {
final isSelected = selected == value;
return Padding(
padding: const EdgeInsets.only(right: 6),
child: FilterChip(
label: Text(label),
selected: isSelected,
onSelected: (_) => onSelected(value),
),
);
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(
children: [
chip('All', null),
chip('Debug+', LogLevel.debug),
chip('Info+', LogLevel.info),
chip('Warn+', LogLevel.warn),
chip('Error+', LogLevel.error),
],
),
);
}
}
class _LogList extends StatelessWidget {
const _LogList({required this.entries});
final List<LogEntry> entries;
@override
Widget build(BuildContext context) {
return ListView.separated(
reverse: true,
itemCount: entries.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final e = entries[entries.length - 1 - index];
return _LogTile(entry: e);
},
);
}
}
class _LogTile extends StatelessWidget {
const _LogTile({required this.entry});
final LogEntry entry;
Color _levelColor(BuildContext context) {
final cs = Theme.of(context).colorScheme;
switch (entry.level) {
case LogLevel.fatal:
case LogLevel.error:
return cs.error;
case LogLevel.warn:
return cs.tertiary;
case LogLevel.info:
return cs.primary;
case LogLevel.debug:
case LogLevel.trace:
return cs.onSurface.withValues(alpha: 0.6);
}
}
@override
Widget build(BuildContext context) {
final time =
'${entry.ts.toLocal().hour.toString().padLeft(2, '0')}:'
'${entry.ts.toLocal().minute.toString().padLeft(2, '0')}:'
'${entry.ts.toLocal().second.toString().padLeft(2, '0')}';
final fields = entry.fields;
return ListTile(
dense: true,
onTap: () => _copyToClipboard(context),
title: Row(
children: [
Text(
entry.level.short,
style: TextStyle(
fontWeight: FontWeight.bold,
color: _levelColor(context),
fontFamily: 'monospace',
),
),
const SizedBox(width: 8),
Text(time, style: const TextStyle(fontFamily: 'monospace')),
const SizedBox(width: 8),
Flexible(
child: Text(
entry.tag,
overflow: TextOverflow.ellipsis,
style:
TextStyle(color: Theme.of(context).colorScheme.secondary),
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(entry.msg),
if (fields != null && fields.isNotEmpty)
Text(
fields.toString(),
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
),
if (entry.err != null)
Text(
entry.err!,
style:
TextStyle(color: Theme.of(context).colorScheme.error),
),
],
),
);
}
void _copyToClipboard(BuildContext context) {
Clipboard.setData(ClipboardData(text: entry.toJsonLine()));
if (context.mounted) {
context.showInfo('Entry copied');
}
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.notes,
size: 48,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4)),
const SizedBox(height: 12),
const Text('No logs match the current filter'),
],
),
);
}
}
+24
View File
@@ -1444,6 +1444,30 @@ class _DataManagementSection extends ConsumerWidget {
const SizedBox(height: 16),
_InstalledAppsBackupToggle(),
const SizedBox(height: 8),
ListTile(
leading: CircleAvatar(
radius: 18,
backgroundColor: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.12),
child: Icon(
Icons.bug_report_outlined,
color: Theme.of(context).colorScheme.primary,
),
),
title: const AutoSizeText(
'Diagnostics',
style: TextStyle(fontWeight: FontWeight.w600),
maxLines: 1,
minFontSize: 12,
),
subtitle: const Text(
'View and export local diagnostic logs',
),
contentPadding: EdgeInsets.zero,
onTap: () => context.push('/profile/diagnostics'),
),
const SizedBox(height: 8),
ListTile(
leading: CircleAvatar(
radius: 18,
+89 -3
View File
@@ -1,8 +1,11 @@
import 'dart:async';
import 'dart:io' show Directory, File, Platform;
import 'dart:isolate';
import 'dart:ui' as ui;
import 'dart:ui' show PlatformDispatcher;
import 'package:background_downloader/background_downloader.dart' hide Request;
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/foundation.dart' show FlutterError, kDebugMode;
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/widgets.dart';
import 'package:permission_handler/permission_handler.dart';
@@ -14,6 +17,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:purplebase/purplebase.dart';
import 'package:workmanager/workmanager.dart';
import 'package:zapstore/router.dart';
import 'package:zapstore/services/log_service.dart';
import 'package:zapstore/services/package_manager/background_package_manager.dart';
import 'package:zapstore/services/package_manager/dummy_package_manager.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
@@ -51,24 +55,106 @@ const _kNotificationPayload = 'updates';
/// Input data key for AppCatalog relay URLs
const kAppCatalogRelaysKey = 'appCatalogRelays';
/// Holds the isolate error port for the workmanager background
/// isolate. Top-level so the GC cannot collect the port while the
/// task is running.
// ignore: unused_element
RawReceivePort? _workmanagerErrorPort;
/// The entry point for WorkManager background tasks.
/// This MUST be a top-level function (not a class method).
@pragma('vm:entry-point')
void callbackDispatcher() {
WidgetsFlutterBinding.ensureInitialized();
ui.DartPluginRegistrant.ensureInitialized();
// Wire all four error sinks for this background isolate.
// LogService.init is fire-and-forget — pre-init writes go to the
// ring buffer and are flushed once disk is ready.
unawaited(LogService.I.init(isolateName: 'workmanager'));
FlutterError.onError = (details) {
FlutterError.presentError(details);
LogService.I.fatal(
'uncaught error',
tag: 'crash',
fields: const {'source': 'flutter'},
err: details.exception,
stack: details.stack,
);
LogService.I.flushSync();
};
PlatformDispatcher.instance.onError = (error, stack) {
LogService.I.fatal(
'uncaught error',
tag: 'crash',
fields: const {'source': 'platform_dispatcher'},
err: error,
stack: stack,
);
LogService.I.flushSync();
return true;
};
final port = RawReceivePort((dynamic pair) {
if (pair is List && pair.length == 2) {
final err = pair[0]?.toString() ?? 'unknown';
final stack = pair[1] == null
? null
: StackTrace.fromString(pair[1].toString());
LogService.I.fatal(
'uncaught error',
tag: 'crash',
fields: const {'source': 'isolate'},
err: err,
stack: stack,
);
LogService.I.flushSync();
}
});
Isolate.current.addErrorListener(port.sendPort);
_workmanagerErrorPort = port;
runZonedGuarded(() {
Workmanager().executeTask((task, inputData) async {
try {
switch (task) {
case kBackgroundUpdateTaskName:
final relayUrls = (inputData?[kAppCatalogRelaysKey] as List<dynamic>?)
final relayUrls =
(inputData?[kAppCatalogRelaysKey] as List<dynamic>?)
?.cast<String>()
.toSet();
return await _checkForUpdatesInBackground(relayUrls);
case kWeeklyCleanupTaskName:
return await _performWeeklyCleanup();
default:
return Future.value(false);
return false;
}
} catch (e, st) {
LogService.I.error(
'background task failed',
tag: 'workmanager',
fields: {'task': task},
err: e,
stack: st,
);
return false;
} finally {
// Ensure entries from this task hit disk before the isolate
// tears down.
await LogService.I.flush();
}
});
}, (error, stack) {
LogService.I.fatal(
'uncaught error',
tag: 'crash',
fields: const {'source': 'zone'},
err: error,
stack: stack,
);
LogService.I.flushSync();
});
}
+11 -3
View File
@@ -1,4 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:zapstore/services/log_service.dart';
/// Converts a deep link URI into a router path string, or returns null if
/// the URI is not a recognized deep link.
@@ -38,12 +38,20 @@ String? resolveDeepLinkPath(Uri uri) {
if (uri.host == 'search' || uri.path == '/search') {
final query = uri.queryParameters['q'];
if (query != null && query.isNotEmpty) {
debugPrint('Market intent: search query = $query');
LogService.I.debug(
'market intent: search query',
tag: 'deep_link',
fields: {'query': query},
);
return '/search/app/$query';
}
}
debugPrint('Market intent: unhandled URI = $uri');
LogService.I.debug(
'market intent: unhandled URI',
tag: 'deep_link',
fields: {'uri': uri.toString()},
);
}
return null;
+700
View File
@@ -0,0 +1,700 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart' show StorageError;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
/// Severity levels for [LogService] entries.
///
/// Ordered from most to least verbose. Setting a [LogService.level] of
/// [warn] will drop [trace], [debug], and [info] entries before they
/// reach the ring buffer or the file.
enum LogLevel {
trace,
debug,
info,
warn,
error,
fatal;
bool operator >=(LogLevel other) => index >= other.index;
String get short {
switch (this) {
case LogLevel.trace:
return 'T';
case LogLevel.debug:
return 'D';
case LogLevel.info:
return 'I';
case LogLevel.warn:
return 'W';
case LogLevel.error:
return 'E';
case LogLevel.fatal:
return 'F';
}
}
static LogLevel? parse(String? name) {
if (name == null) return null;
for (final l in LogLevel.values) {
if (l.name == name) return l;
}
return null;
}
}
/// A single log record. Immutable, JSON-serialisable.
class LogEntry {
final DateTime ts;
final LogLevel level;
final String tag;
final String msg;
final Map<String, Object?>? fields;
final String? err;
final String? stack;
final String isolate;
const LogEntry({
required this.ts,
required this.level,
required this.tag,
required this.msg,
this.fields,
this.err,
this.stack,
required this.isolate,
});
/// Encode as a single NDJSON line (without trailing newline).
String toJsonLine() {
final m = <String, Object?>{
'ts': ts.toUtc().toIso8601String(),
'level': level.name,
'tag': tag,
'msg': msg,
'isolate': isolate,
};
if (fields != null && fields!.isNotEmpty) m['fields'] = fields;
if (err != null) m['err'] = err;
if (stack != null) m['stack'] = stack;
return jsonEncode(m);
}
/// Decode a single NDJSON line. Returns null on malformed input so callers
/// can skip corrupted lines without aborting a whole file read.
static LogEntry? tryDecode(String line) {
if (line.isEmpty) return null;
try {
final m = jsonDecode(line) as Map<String, dynamic>;
final tsRaw = m['ts'] as String?;
final levelName = m['level'] as String?;
if (tsRaw == null || levelName == null) return null;
final ts = DateTime.tryParse(tsRaw);
final level = LogLevel.parse(levelName);
if (ts == null || level == null) return null;
return LogEntry(
ts: ts,
level: level,
tag: (m['tag'] as String?) ?? '',
msg: (m['msg'] as String?) ?? '',
fields: (m['fields'] as Map?)?.cast<String, Object?>(),
err: m['err'] as String?,
stack: m['stack'] as String?,
isolate: (m['isolate'] as String?) ?? 'unknown',
);
} catch (_) {
return null;
}
}
}
/// Maximum size of the active log file before rotation.
const int kLogMaxFileBytes = 10 * 1024 * 1024; // 10 MB
/// Maximum number of historical (rotated) log files retained.
const int kLogMaxRotations = 5;
/// Maximum length of any single string field value in a [LogEntry].
/// Longer strings are truncated with a `…(truncated)` marker.
const int kLogMaxFieldBytes = 4 * 1024;
/// Maximum entries kept in the in-memory ring buffer.
const int kLogRingBufferSize = 500;
/// Append-only structured logger.
///
/// `LogService` is designed so that:
/// * The UI thread never performs disk I/O (writes are batched and
/// flushed on a background `Future`).
/// * Multiple isolates can write to the same file safely (each batch
/// takes an advisory file lock for the duration of the write).
/// * Logs survive a crash: writes flush on a microtask boundary and
/// [flushSync] can be called from a fatal handler before re-throwing.
///
/// Use [LogService.I] (the singleton) from app code. In tests use
/// [LogService.forTesting] to construct an instance with a custom
/// directory.
class LogService {
/// The active singleton, available after [init] has been awaited at
/// least once. Calls before [init] go to the in-memory ring buffer
/// only.
static final LogService I = LogService._();
LogService._();
/// Public for tests only.
LogService.forTesting({
required Directory directory,
required String isolate,
this.level = LogLevel.debug,
}) : _dir = directory,
_isolateName = isolate,
_initialised = true {
_activeFile = File(p.join(directory.path, _activeFileName));
}
static const String _activeFileName = 'zapstore.log';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
Directory? _dir;
File? _activeFile;
String _isolateName = 'main';
/// Minimum severity that will be recorded. Entries below this level
/// are dropped before they reach the ring buffer or disk.
LogLevel level = LogLevel.debug;
bool _initialised = false;
bool _diskDisabled = false;
/// Ring buffer of recent entries, newest at the end.
final Queue<LogEntry> _ring = Queue<LogEntry>();
/// Pending entries waiting to be flushed.
final List<LogEntry> _pending = [];
/// Single-flight flush future; set while a flush is in flight.
Future<void>? _flushFuture;
/// True while a flush is scheduled but not yet running.
bool _flushScheduled = false;
/// Last time we logged a "disk full" stderr warning. Rate-limited to
/// at most one per minute so a runaway logger does not spam stderr.
DateTime? _lastDiskFullWarn;
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
String get isolateName => _isolateName;
/// Whether disk writes have been disabled this session due to an
/// unrecoverable I/O error (e.g. read-only `logs/`). The ring buffer
/// still works.
bool get diskDisabled => _diskDisabled;
/// Initialise the singleton. Safe to call multiple times; only the
/// first call has an effect. [isolateName] tags every entry written
/// from this isolate so cross-isolate logs are distinguishable.
Future<void> init({
required String isolateName,
LogLevel level = LogLevel.debug,
}) async {
if (_initialised) return;
_isolateName = isolateName;
this.level = level;
try {
final base = await getApplicationSupportDirectory();
final logDir = Directory(p.join(base.path, 'logs'));
if (!logDir.existsSync()) {
logDir.createSync(recursive: true);
}
_dir = logDir;
_activeFile = File(p.join(logDir.path, _activeFileName));
// Probe write access so we fail fast if the dir is read-only.
_activeFile!.openSync(mode: FileMode.append).closeSync();
} catch (e, st) {
_diskDisabled = true;
// Do not throw — logging must never crash the app. Surface a
// single warning entry to the ring buffer.
_ringAdd(_makeEntry(
level: LogLevel.warn,
tag: 'log_service',
msg: 'Disk logging disabled',
err: e.toString(),
stack: st.toString(),
));
} finally {
_initialised = true;
}
}
void trace(String msg,
{String tag = 'app',
Map<String, Object?>? fields,
Object? err,
StackTrace? stack}) =>
log(LogLevel.trace, msg,
tag: tag, fields: fields, err: err, stack: stack);
void debug(String msg,
{String tag = 'app',
Map<String, Object?>? fields,
Object? err,
StackTrace? stack}) =>
log(LogLevel.debug, msg,
tag: tag, fields: fields, err: err, stack: stack);
void info(String msg,
{String tag = 'app',
Map<String, Object?>? fields,
Object? err,
StackTrace? stack}) =>
log(LogLevel.info, msg,
tag: tag, fields: fields, err: err, stack: stack);
void warn(String msg,
{String tag = 'app',
Map<String, Object?>? fields,
Object? err,
StackTrace? stack}) =>
log(LogLevel.warn, msg,
tag: tag, fields: fields, err: err, stack: stack);
void error(String msg,
{String tag = 'app',
Map<String, Object?>? fields,
Object? err,
StackTrace? stack}) =>
log(LogLevel.error, msg,
tag: tag, fields: fields, err: err, stack: stack);
void fatal(String msg,
{String tag = 'app',
Map<String, Object?>? fields,
Object? err,
StackTrace? stack}) =>
log(LogLevel.fatal, msg,
tag: tag, fields: fields, err: err, stack: stack);
/// Record a log entry. Always non-blocking. The entry is added to the
/// ring buffer immediately; disk write is scheduled on a microtask.
void log(
LogLevel level,
String msg, {
String tag = 'app',
Map<String, Object?>? fields,
Object? err,
StackTrace? stack,
}) {
if (level.index < this.level.index) return;
final entry = _makeEntry(
level: level,
tag: tag,
msg: msg,
fields: fields,
err: err?.toString(),
stack: stack?.toString(),
);
_ringAdd(entry);
if (kDebugMode) {
// Mirror to stderr in debug builds so `flutter run` shows the entry.
// Use separate print() calls per field so adb logcat's per-line
// truncation (~4 KB) does not eat the stack trace — which is the
// most useful diagnostic when an error is logged.
// ignore: avoid_print
print('[${entry.level.short}/${entry.tag}] ${entry.msg}');
if (entry.err != null) {
// ignore: avoid_print
print(' err: ${entry.err}');
}
if (entry.stack != null) {
// ignore: avoid_print
print(' stack:\n${entry.stack}');
}
}
if (_diskDisabled) return;
_pending.add(entry);
_scheduleFlush();
}
/// A read-only snapshot of the in-memory ring buffer, oldest first.
List<LogEntry> ringSnapshot() => List.unmodifiable(_ring);
/// All log files currently on disk, newest (active) first.
/// Returns an empty list if disk logging is disabled or the directory
/// does not exist.
List<File> currentFiles() {
final dir = _dir;
if (dir == null || !dir.existsSync()) return const [];
final files = <File>[];
final active = File(p.join(dir.path, _activeFileName));
if (active.existsSync()) files.add(active);
for (var i = 1; i <= kLogMaxRotations; i++) {
final f = File(p.join(dir.path, '$_activeFileName.$i'));
if (f.existsSync()) files.add(f);
}
return files;
}
/// Read recent entries from disk (newest last), capped at [max] lines
/// counted from the tail of the active file. Skips malformed lines.
Future<List<LogEntry>> readTail({int max = 1000}) async {
final file = _activeFile;
if (file == null || !file.existsSync()) return const [];
final lines = await file.readAsLines();
final start = lines.length > max ? lines.length - max : 0;
final out = <LogEntry>[];
for (var i = start; i < lines.length; i++) {
final e = LogEntry.tryDecode(lines[i]);
if (e != null) out.add(e);
}
return out;
}
/// Wait for any pending flush to complete and drain any remaining
/// entries. Used in tests and from fatal handlers.
///
/// Concurrency-safe: if multiple callers invoke [flush] in the same
/// tick they all observe the same in-flight write and any newly
/// queued entries are drained in order.
Future<void> flush() async {
while (true) {
final f = _flushFuture;
if (f != null) {
await f;
continue;
}
if (_pending.isEmpty) return;
// No flush in flight but entries pending — kick one off and
// record it as the active future so concurrent callers join.
_flushFuture = _runFlush();
await _flushFuture;
}
}
/// Synchronous flush. Use only from fatal handlers where the isolate
/// is about to die. Blocks the calling isolate's event loop.
void flushSync() {
if (_diskDisabled || _pending.isEmpty) return;
final file = _activeFile;
if (file == null) return;
try {
_writeBatchSync(file, _pending);
_pending.clear();
_maybeRotateSync();
} catch (_) {
// Last-resort: drop pending. Logging must never crash the app.
}
}
/// Delete all log files and clear the ring buffer.
Future<void> clear() async {
_ring.clear();
_pending.clear();
final dir = _dir;
if (dir == null) return;
for (final f in currentFiles()) {
try {
f.deleteSync();
} catch (_) {}
}
}
// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------
LogEntry _makeEntry({
required LogLevel level,
required String tag,
required String msg,
Map<String, Object?>? fields,
String? err,
String? stack,
}) {
return LogEntry(
ts: DateTime.now(),
level: level,
tag: tag,
msg: LogRedactor.scrub(msg),
fields: fields == null ? null : LogRedactor.scrubFields(fields),
err: err == null ? null : LogRedactor.scrub(err),
stack: stack == null ? null : LogRedactor.scrub(stack),
isolate: _isolateName,
);
}
void _ringAdd(LogEntry e) {
_ring.addLast(e);
while (_ring.length > kLogRingBufferSize) {
_ring.removeFirst();
}
}
void _scheduleFlush() {
if (_flushScheduled || _flushFuture != null) return;
_flushScheduled = true;
scheduleMicrotask(() {
_flushScheduled = false;
// Another caller (e.g. `flush()`) may have started the flush
// already; if so, do nothing — entries added after that will
// re-schedule in the `_runFlush` finally block.
if (_flushFuture != null) return;
_flushFuture = _runFlush();
});
}
Future<void> _runFlush() async {
if (_pending.isEmpty || _diskDisabled) {
_flushFuture = null;
return;
}
final file = _activeFile;
if (file == null) {
_pending.clear();
_flushFuture = null;
return;
}
final batch = List<LogEntry>.from(_pending);
_pending.clear();
try {
await _writeBatchAsync(file, batch);
await _maybeRotateAsync();
} on FileSystemException catch (e) {
_onDiskWriteFailure(e);
} catch (_) {
// Swallow — logging must never crash the app.
} finally {
_flushFuture = null;
// If new entries arrived while we were flushing, schedule again.
if (_pending.isNotEmpty) _scheduleFlush();
}
}
Future<void> _writeBatchAsync(File file, List<LogEntry> batch) async {
final raf = await file.open(mode: FileMode.append);
try {
// Advisory exclusive lock — coordinates writes across isolates.
await raf.lock(FileLock.blockingExclusive);
final buffer = StringBuffer();
for (final e in batch) {
buffer
..write(e.toJsonLine())
..write('\n');
}
await raf.writeString(buffer.toString());
await raf.flush();
await raf.unlock();
} finally {
await raf.close();
}
}
void _writeBatchSync(File file, List<LogEntry> batch) {
final raf = file.openSync(mode: FileMode.append);
try {
raf.lockSync(FileLock.blockingExclusive);
final buffer = StringBuffer();
for (final e in batch) {
buffer
..write(e.toJsonLine())
..write('\n');
}
raf.writeStringSync(buffer.toString());
raf.flushSync();
raf.unlockSync();
} finally {
raf.closeSync();
}
}
Future<void> _maybeRotateAsync() async {
final file = _activeFile;
if (file == null) return;
try {
final size = await file.length();
if (size < kLogMaxFileBytes) return;
_rotateFiles();
} catch (_) {}
}
void _maybeRotateSync() {
final file = _activeFile;
if (file == null) return;
try {
if (file.lengthSync() < kLogMaxFileBytes) return;
_rotateFiles();
} catch (_) {}
}
/// Rotate `zapstore.log` → `.1` → `.2` → … → `.N`. The oldest is
/// deleted. Sync I/O — only called from a flush boundary, never the
/// UI thread directly.
void _rotateFiles() {
final dir = _dir;
if (dir == null) return;
try {
// Delete the oldest if it exists.
final oldest =
File(p.join(dir.path, '$_activeFileName.$kLogMaxRotations'));
if (oldest.existsSync()) oldest.deleteSync();
// Shift .N-1 → .N, .N-2 → .N-1, ... .1 → .2
for (var i = kLogMaxRotations - 1; i >= 1; i--) {
final src = File(p.join(dir.path, '$_activeFileName.$i'));
if (src.existsSync()) {
src.renameSync(p.join(dir.path, '$_activeFileName.${i + 1}'));
}
}
// Active → .1
final active = File(p.join(dir.path, _activeFileName));
if (active.existsSync()) {
active.renameSync(p.join(dir.path, '$_activeFileName.1'));
}
} catch (_) {
// Rotation failures are non-fatal; the active file just keeps growing
// until the next attempt.
}
}
void _onDiskWriteFailure(FileSystemException e) {
final now = DateTime.now();
final last = _lastDiskFullWarn;
if (last == null || now.difference(last) > const Duration(minutes: 1)) {
_lastDiskFullWarn = now;
// ignore: avoid_print
print('[LogService] disk write failed: ${e.message}');
}
// Try to free space by rotating + dropping the oldest.
try {
_rotateFiles();
} catch (_) {}
}
}
/// Redacts known-sensitive patterns from log strings before they hit
/// the ring buffer or disk. Redaction is mandatory at write time so
/// the on-disk log is already safe to share.
///
/// Patterns covered (as defined in FEAT-005):
/// * `nsec1…` Bech32 secret keys
/// * `ncryptsec1…` encrypted secret keys
/// * `nostr+walletconnect://…` NWC URIs
///
/// Plaintext of NIP-04 / NIP-44 / NIP-17 events (kinds 4, 13, 1059) is
/// the responsibility of the call site — pass [redactPlaintext] around
/// the relevant value. The redactor below cannot detect them from a
/// raw string because the kind is structural, not lexical.
class LogRedactor {
static final RegExp _nsec = RegExp(r'\bnsec1[ac-hj-np-z02-9]{6,}\b');
static final RegExp _ncryptsec =
RegExp(r'\bncryptsec1[ac-hj-np-z02-9]{6,}\b');
static final RegExp _nwc =
RegExp(r'nostr\+walletconnect://[^\s"\\]+', caseSensitive: false);
/// Replace all known secret patterns in [s] with `[REDACTED:*]`
/// markers. Also enforces [kLogMaxFieldBytes] truncation.
static String scrub(String s) {
var out = s
.replaceAll(_nsec, '[REDACTED:nsec]')
.replaceAll(_ncryptsec, '[REDACTED:ncryptsec]')
.replaceAll(_nwc, '[REDACTED:nwc]');
if (out.length > kLogMaxFieldBytes) {
out = '${out.substring(0, kLogMaxFieldBytes)}…(truncated)';
}
return out;
}
/// Scrub every value in a fields map. Nested maps and lists are
/// walked recursively. Non-string scalar values pass through
/// unchanged.
static Map<String, Object?> scrubFields(Map<String, Object?> fields) {
final out = <String, Object?>{};
fields.forEach((k, v) {
out[k] = _scrubValue(v);
});
return out;
}
static Object? _scrubValue(Object? v) {
if (v == null) return null;
if (v is String) return scrub(v);
if (v is Map) {
return scrubFields(v.cast<String, Object?>());
}
if (v is Iterable) {
return v.map(_scrubValue).toList();
}
return v;
}
/// Convenience for call sites that have already-decrypted plaintext
/// they should never pass to the logger. Always returns the marker.
static String redactPlaintext({String kind = 'plaintext'}) =>
'[REDACTED:$kind]';
}
/// Riverpod observer that funnels provider failures and `StorageError`
/// states into [LogService].
///
/// - `providerDidFail` (provider build threw, or async source emitted an
/// error) is logged at `error` level.
/// - `didUpdateProvider` is logged at `warn` level when the new value
/// is a `StorageError<*>` — these are common in normal operation
/// (network blips, relay errors) so they would be too noisy at
/// `error`.
class LoggingProviderObserver extends ProviderObserver {
const LoggingProviderObserver();
@override
void providerDidFail(
ProviderBase<Object?> provider,
Object error,
StackTrace stackTrace,
ProviderContainer container,
) {
LogService.I.error(
'provider failed',
tag: 'riverpod',
fields: {'provider': _providerLabel(provider)},
err: error,
stack: stackTrace,
);
}
@override
void didUpdateProvider(
ProviderBase<Object?> provider,
Object? previousValue,
Object? newValue,
ProviderContainer container,
) {
if (newValue is StorageError) {
LogService.I.warn(
'storage error',
tag: 'riverpod',
fields: {'provider': _providerLabel(provider)},
err: newValue.exception,
stack: newValue.stackTrace,
);
}
}
static String _providerLabel(ProviderBase<Object?> provider) {
final name = provider.name;
if (name != null && name.isNotEmpty) return name;
return provider.runtimeType.toString();
}
}
@@ -1,9 +1,9 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:models/models.dart';
import 'package:zapstore/services/log_service.dart';
import 'package:zapstore/services/package_manager/installed_packages_snapshot.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart';
@@ -100,11 +100,18 @@ final class AndroidPackageManager extends PackageManager {
_eventSubscription = _eventChannel.receiveBroadcastStream().listen(
_handleInstallEvent,
onError: (e) {
debugPrint('[PackageManager] EventChannel error: $e');
LogService.I.warn(
'EventChannel error',
tag: 'package_manager',
err: e,
);
_attemptEventStreamReconnect();
},
onDone: () {
debugPrint('[PackageManager] EventChannel closed unexpectedly');
LogService.I.warn(
'EventChannel closed unexpectedly',
tag: 'package_manager',
);
_attemptEventStreamReconnect();
},
);
@@ -120,7 +127,10 @@ final class AndroidPackageManager extends PackageManager {
Future.delayed(const Duration(seconds: 2), () {
_isReconnecting = false;
if (mounted) {
debugPrint('[PackageManager] Attempting EventChannel reconnect');
LogService.I.debug(
'attempting EventChannel reconnect',
tag: 'package_manager',
);
_setupEventStream();
}
});
@@ -130,7 +140,11 @@ final class AndroidPackageManager extends PackageManager {
/// Events arrive sequentially (Dart is single-threaded), no lock needed.
void _handleInstallEvent(dynamic event) {
if (event is! Map) {
debugPrint('[PackageManager] Ignoring non-map event: $event');
LogService.I.debug(
'ignoring non-map event',
tag: 'package_manager',
fields: {'event': event.toString()},
);
return;
}
@@ -143,13 +157,18 @@ final class AndroidPackageManager extends PackageManager {
final status = InstallStatusX.tryParse(statusRaw);
if (appId == null || statusRaw == null) {
debugPrint('[PackageManager] Ignoring event with null appId or status');
LogService.I.debug(
'ignoring event with null appId or status',
tag: 'package_manager',
);
return;
}
if (status == null) {
debugPrint(
'[PackageManager] Ignoring event with unknown status: $statusRaw',
LogService.I.debug(
'ignoring event with unknown status',
tag: 'package_manager',
fields: {'status': statusRaw},
);
return;
}
@@ -162,8 +181,10 @@ final class AndroidPackageManager extends PackageManager {
// Abort the native session to clean up and allow user to retry from clean state.
// Only attempt abort once per appId to prevent spam when native keeps sending events.
if (_abortedOrphans.add(appId)) {
debugPrint(
'[PackageManager] No tracked operation for appId=$appId, aborting orphaned native session',
LogService.I.warn(
'no tracked operation, aborting orphaned native session',
tag: 'package_manager',
fields: {'appId': appId},
);
// Release the install slot in case this app was the active install
// (e.g., sync cleared the operation before the native event arrived).
@@ -589,12 +610,20 @@ final class AndroidPackageManager extends PackageManager {
final wasCommitted = resultMap['wasCommitted'] as bool? ?? false;
if (wasCommitted) {
debugPrint(
'[PackageManager] abortInstall: Session was committed, Android may still complete install for $appId',
LogService.I.info(
'abortInstall: session was committed, Android may still complete install',
tag: 'package_manager',
fields: {'appId': appId},
);
}
} catch (e) {
debugPrint('[PackageManager] abortInstall failed for $appId: $e');
} catch (e, st) {
LogService.I.warn(
'abortInstall failed',
tag: 'package_manager',
fields: {'appId': appId},
err: e,
stack: st,
);
}
// Clear operation on Dart side regardless of native result
@@ -765,9 +794,14 @@ final class AndroidPackageManager extends PackageManager {
// Transition to Completed (not clearOperation) so the op stays until
// clearCompletedOperations runs (auto-clear timer or navigation away).
if (completed) {
debugPrint(
'[PackageManager] Sync: completing operation for $appId '
'(installedVc=$installedVc, targetVc=$targetVc)',
LogService.I.debug(
'sync: completing operation',
tag: 'package_manager',
fields: {
'appId': appId,
'installedVc': installedVc,
'targetVc': targetVc,
},
);
// Sync fallback: state.installed was already overwritten with native
// data above (line 723), so we must NOT call _updateInstalledPackage
@@ -777,9 +811,14 @@ final class AndroidPackageManager extends PackageManager {
setOperation(appId, Completed(target: op.target, isUpdate: true));
clearInstallSlot(appId);
} else {
debugPrint(
'[PackageManager] Sync: keeping operation for $appId '
'(installedVc=$installedVc, targetVc=$targetVc)',
LogService.I.debug(
'sync: keeping operation',
tag: 'package_manager',
fields: {
'appId': appId,
'installedVc': installedVc,
'targetVc': targetVc,
},
);
}
}
@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:zapstore/services/log_service.dart';
/// Device capability information for adaptive behavior.
/// Cached at startup since these values don't change during session.
@@ -51,10 +52,22 @@ class DeviceCapabilitiesCache {
maxConcurrentDownloads: maxDownloads,
);
debugPrint('[DeviceCapabilities] Initialized: $_cached');
LogService.I.debug(
'device capabilities initialised',
tag: 'device',
fields: {
'totalRamMB': totalRamMB,
'maxConcurrentDownloads': maxDownloads,
},
);
return _cached!;
} catch (e) {
debugPrint('[DeviceCapabilities] Failed to detect: $e, using fallback');
} catch (e, st) {
LogService.I.warn(
'device capabilities detection failed, using fallback',
tag: 'device',
err: e,
stack: st,
);
_cached = DeviceCapabilities.fallback;
return _cached!;
}
@@ -1,9 +1,9 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:zapstore/services/log_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
class InstalledPackagesSnapshot {
@@ -40,11 +40,14 @@ class InstalledPackagesSnapshot {
await file.delete();
}
await tmp.rename(file.path);
} catch (e) {
} catch (e, st) {
// Best-effort snapshot only.
if (kDebugMode) {
debugPrint('[InstalledPackagesSnapshot] Save failed: $e');
}
LogService.I.warn(
'installed packages snapshot save failed',
tag: 'package_manager',
err: e,
stack: st,
);
}
}
@@ -57,8 +60,12 @@ class InstalledPackagesSnapshot {
final decoded = jsonDecode(raw);
if (decoded is! Map) return {};
final version = decoded['v'];
if (version != null && version is! int && kDebugMode) {
debugPrint('[InstalledPackagesSnapshot] Unknown schema: $version');
if (version != null && version is! int) {
LogService.I.warn(
'installed packages snapshot: unknown schema',
tag: 'package_manager',
fields: {'version': version.toString()},
);
}
final installed = decoded['installed'];
if (installed is! List) return {};
@@ -84,10 +91,13 @@ class InstalledPackagesSnapshot {
);
}
return result;
} catch (e) {
if (kDebugMode) {
debugPrint('[InstalledPackagesSnapshot] Load failed: $e');
}
} catch (e, st) {
LogService.I.warn(
'installed packages snapshot load failed',
tag: 'package_manager',
err: e,
stack: st,
);
return {};
}
}
@@ -6,6 +6,7 @@ import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/services/log_service.dart';
import 'package:zapstore/services/package_manager/device_capabilities.dart';
import 'package:zapstore/services/package_manager/dummy_package_manager.dart';
import 'package:zapstore/services/package_manager/install_operation.dart';
@@ -173,8 +174,13 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
],
androidConfig: [(Config.useCacheDir, false)],
);
} catch (e) {
debugPrint('FileDownloader configure failed: $e');
} catch (e, st) {
LogService.I.warn(
'FileDownloader configure failed',
tag: 'package_manager',
err: e,
stack: st,
);
}
// Note: We don't call configureNotificationForGroup because we handle
@@ -185,8 +191,13 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
taskStatusCallback: _handleDownloadUpdate,
taskProgressCallback: _handleDownloadUpdate,
);
} catch (e) {
debugPrint('FileDownloader registerCallbacks failed: $e');
} catch (e, st) {
LogService.I.warn(
'FileDownloader registerCallbacks failed',
tag: 'package_manager',
err: e,
stack: st,
);
}
await _restoreOperations();
@@ -232,8 +243,10 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
final lastProgress = op.lastProgressAt;
if (now.difference(lastProgress) <= watchdogTimeout) continue;
debugPrint(
'[PackageManager] Watchdog: $appId download stalled, transitioning to error',
LogService.I.warn(
'watchdog: download stalled, transitioning to error',
tag: 'package_manager',
fields: {'appId': appId},
);
activeDownloads.remove(appId);
@@ -437,8 +450,10 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
final paused = await _downloader.pause(task);
if (!paused) {
// Pause failed - task may be stuck, transition to failed state
debugPrint(
'[PackageManager] Pause returned false for $appId, marking as failed',
LogService.I.warn(
'pause returned false, marking as failed',
tag: 'package_manager',
fields: {'appId': appId},
);
activeDownloads.remove(appId);
setOperation(
@@ -457,8 +472,10 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
}
} else {
// Task not found - download is in zombie state
debugPrint(
'[PackageManager] Task not found for $appId, marking as failed',
LogService.I.warn(
'task not found, marking as failed',
tag: 'package_manager',
fields: {'appId': appId},
);
activeDownloads.remove(appId);
setOperation(
@@ -471,8 +488,14 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
);
scheduleProcessQueue();
}
} catch (e) {
debugPrint('[PackageManager] Failed to pause download for $appId: $e');
} catch (e, st) {
LogService.I.warn(
'failed to pause download',
tag: 'package_manager',
fields: {'appId': appId},
err: e,
stack: st,
);
activeDownloads.remove(appId);
setOperation(
appId,
@@ -506,7 +529,11 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
);
} else {
// Task not found - transition to error
debugPrint('[PackageManager] Resume failed: task not found for $appId');
LogService.I.warn(
'resume failed: task not found',
tag: 'package_manager',
fields: {'appId': appId},
);
setOperation(
appId,
OperationFailed(
@@ -516,8 +543,14 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
),
);
}
} catch (e) {
debugPrint('[PackageManager] Failed to resume download for $appId: $e');
} catch (e, st) {
LogService.I.warn(
'failed to resume download',
tag: 'package_manager',
fields: {'appId': appId},
err: e,
stack: st,
);
setOperation(
appId,
OperationFailed(
@@ -1163,8 +1196,13 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
await _restoreOperation(appId, record, task, fileMetadata);
}
} catch (e) {
debugPrint('Failed to restore operations: $e');
} catch (e, st) {
LogService.I.warn(
'failed to restore operations',
tag: 'package_manager',
err: e,
stack: st,
);
}
}
+8
View File
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:amber_signer/amber_signer.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:zapstore/services/log_service.dart';
const _storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
@@ -17,6 +18,7 @@ class LocalSettings {
final DateTime? seenUntil;
final DateTime? deletionSyncedUntil;
final bool installedAppsBackupEnabled;
final LogLevel logLevel;
const LocalSettings({
this.nwcConnectionString,
@@ -25,6 +27,7 @@ class LocalSettings {
this.seenUntil,
this.deletionSyncedUntil,
this.installedAppsBackupEnabled = false,
this.logLevel = LogLevel.debug,
});
bool get hasNwcString => nwcConnectionString?.isNotEmpty == true;
@@ -37,6 +40,7 @@ class LocalSettings {
seenUntil: _parseDateTime(json['seenUntil']),
deletionSyncedUntil: _parseDateTime(json['deletionSyncedUntil']),
installedAppsBackupEnabled: json['backupEnabled'] as bool? ?? false,
logLevel: LogLevel.parse(json['logLevel'] as String?) ?? LogLevel.debug,
);
}
@@ -49,6 +53,8 @@ class LocalSettings {
if (deletionSyncedUntil != null)
'deletionSyncedUntil': deletionSyncedUntil!.millisecondsSinceEpoch,
if (installedAppsBackupEnabled) 'backupEnabled': true,
// Only persist non-default value to keep blob small.
if (logLevel != LogLevel.debug) 'logLevel': logLevel.name,
};
LocalSettings copyWith({
@@ -58,6 +64,7 @@ class LocalSettings {
DateTime? seenUntil,
DateTime? deletionSyncedUntil,
bool? installedAppsBackupEnabled,
LogLevel? logLevel,
bool clearNwc = false,
}) {
return LocalSettings(
@@ -69,6 +76,7 @@ class LocalSettings {
deletionSyncedUntil: deletionSyncedUntil ?? this.deletionSyncedUntil,
installedAppsBackupEnabled:
installedAppsBackupEnabled ?? this.installedAppsBackupEnabled,
logLevel: logLevel ?? this.logLevel,
);
}
+15 -5
View File
@@ -1,11 +1,11 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/main.dart';
import 'package:purplebase/purplebase.dart';
import 'package:zapstore/services/catalog_fetcher.dart';
import 'package:zapstore/services/log_service.dart';
import 'package:zapstore/services/deletion_processor.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/services/settings_service.dart';
@@ -128,8 +128,13 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
clearError: true,
);
unawaited(_backupInstalledApps());
} catch (e) {
debugPrint('[UpdatePoller] Check failed: $e');
} catch (e, st) {
LogService.I.warn(
'update check failed',
tag: 'updates',
err: e,
stack: st,
);
state = state.copyWith(
isChecking: false,
lastCheckTime: DateTime.now(),
@@ -235,8 +240,13 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
await storage.save({signed});
await storage.publish({signed}, relays: {'AppCatalog', 'social'});
_lastBackedUpIds = appIds;
} catch (e) {
debugPrint('[InstalledAppsBackup] Backup failed: $e');
} catch (e, st) {
LogService.I.warn(
'installed apps backup failed',
tag: 'updates',
err: e,
stack: st,
);
}
}
+3 -3
View File
@@ -51,7 +51,7 @@ packages:
source: hosted
version: "1.0.4"
archive:
dependency: transitive
dependency: "direct main"
description:
name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
@@ -870,8 +870,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: f3243d2b5d64f3a5210e14929ea2b3ca2c20eab3
resolved-ref: f3243d2b5d64f3a5210e14929ea2b3ca2c20eab3
ref: "82cd7e8"
resolved-ref: "82cd7e82011d196acfde4dd0e912501e477dc072"
url: "https://github.com/purplebase/purplebase"
source: git
version: "0.3.3"
+2 -1
View File
@@ -56,6 +56,7 @@ dependencies:
workmanager: ^0.9.0+3
flutter_local_notifications: ^19.5.0
device_info_plus: ^12.0.0
archive: ^4.0.7
dependency_overrides:
models:
@@ -67,7 +68,7 @@ dependency_overrides:
# path: ../../purplebase/purplebase
git:
url: https://github.com/purplebase/purplebase
ref: f3243d2b5d64f3a5210e14929ea2b3ca2c20eab3
ref: 82cd7e8
amber_signer:
# path: ../../purplebase/amber_signer
git:
+125
View File
@@ -0,0 +1,125 @@
# 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.
+149
View File
@@ -0,0 +1,149 @@
# WORK-008 — Local Diagnostic Logs
**Feature:** FEAT-005-diagnostic-logs.md
**Status:** Complete
## Tasks
- [x] 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)
- [x] 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
- [x] 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()`
- [x] 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`
- [x] 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()]`
- [x] 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
- [x] 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
- [x] 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
- [x] 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
- [x] 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
- [x] 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).
+345
View File
@@ -0,0 +1,345 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:zapstore/services/log_service.dart';
/// Helper: build an isolated `LogService` writing to a temp dir.
Future<({LogService log, Directory dir})> _newService(
{String isolate = 'main', LogLevel level = LogLevel.debug}) async {
final dir = await Directory.systemTemp.createTemp('log_service_test_');
final svc = LogService.forTesting(
directory: dir,
isolate: isolate,
level: level,
);
return (log: svc, dir: dir);
}
File _activeFile(Directory dir) => File(p.join(dir.path, 'zapstore.log'));
void main() {
group('LogService basics', () {
test('records to ring buffer and disk', () async {
final (:log, :dir) = await _newService();
log.info('hello world', tag: 'test');
log.warn('something off', tag: 'test', fields: {'k': 1});
await log.flush();
final ring = log.ringSnapshot();
expect(ring, hasLength(2));
expect(ring[0].msg, 'hello world');
expect(ring[0].level, LogLevel.info);
expect(ring[1].fields, {'k': 1});
final lines = await _activeFile(dir).readAsLines();
expect(lines, hasLength(2));
final decoded = lines.map(LogEntry.tryDecode).toList();
expect(decoded.every((e) => e != null), isTrue);
expect(decoded[1]!.fields, {'k': 1});
});
test('respects minimum level', () async {
final (:log, :dir) = await _newService(level: LogLevel.warn);
log.debug('dropped');
log.info('also dropped');
log.warn('kept');
log.error('kept too');
await log.flush();
expect(log.ringSnapshot(), hasLength(2));
final lines = await _activeFile(dir).readAsLines();
expect(lines, hasLength(2));
});
test('ring buffer is bounded', () async {
final (:log, :dir) = await _newService();
// ignore: unused_local_variable
final _ = dir;
for (var i = 0; i < kLogRingBufferSize + 50; i++) {
log.debug('msg $i');
}
await log.flush();
final ring = log.ringSnapshot();
expect(ring, hasLength(kLogRingBufferSize));
expect(ring.last.msg, 'msg ${kLogRingBufferSize + 49}');
});
test('readTail returns latest entries', () async {
final (:log, :dir) = await _newService();
// ignore: unused_local_variable
final _ = dir;
for (var i = 0; i < 10; i++) {
log.info('m$i');
}
await log.flush();
final tail = await log.readTail(max: 3);
expect(tail.map((e) => e.msg).toList(), ['m7', 'm8', 'm9']);
});
test('clear deletes files and empties ring', () async {
final (:log, :dir) = await _newService();
log.info('one');
await log.flush();
expect(_activeFile(dir).existsSync(), isTrue);
await log.clear();
expect(_activeFile(dir).existsSync(), isFalse);
expect(log.ringSnapshot(), isEmpty);
});
});
group('Redaction', () {
const sampleNsec =
'nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5';
const sampleNcrypt =
'ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd';
const sampleNwc =
'nostr+walletconnect://abc123?relay=wss%3A%2F%2Frelay.example.com&secret=deadbeef';
test('scrub removes nsec / ncryptsec / NWC', () {
expect(LogRedactor.scrub('my key is $sampleNsec ok'),
'my key is [REDACTED:nsec] ok');
expect(LogRedactor.scrub('my key is $sampleNcrypt ok'),
'my key is [REDACTED:ncryptsec] ok');
expect(LogRedactor.scrub('uri=$sampleNwc end'),
'uri=[REDACTED:nwc] end');
});
test('logger redacts in msg, fields, err, and stack', () async {
final (:log, :dir) = await _newService();
log.error(
'failed with $sampleNsec',
tag: 'redact',
fields: {
'connection': sampleNwc,
'nested': {'inner': sampleNcrypt},
'list': ['ok', sampleNsec],
},
err: 'caused by $sampleNcrypt',
stack: StackTrace.fromString('at foo() $sampleNwc'),
);
await log.flush();
final raw = await _activeFile(dir).readAsString();
expect(raw.contains('nsec1'), isFalse,
reason: 'nsec must never appear in log output');
expect(raw.contains('ncryptsec1'), isFalse);
expect(raw.contains('nostr+walletconnect://'), isFalse);
expect(raw, contains('[REDACTED:nsec]'));
expect(raw, contains('[REDACTED:ncryptsec]'));
expect(raw, contains('[REDACTED:nwc]'));
});
test('truncates oversized field strings', () {
final big = 'x' * (kLogMaxFieldBytes + 100);
final out = LogRedactor.scrub(big);
expect(out.endsWith('…(truncated)'), isTrue);
expect(out.length, kLogMaxFieldBytes + '…(truncated)'.length);
});
});
group('Rotation', () {
test('rotates when active file exceeds max size', () async {
final (:log, :dir) = await _newService();
// Pre-fill the active file slightly OVER the limit so the next
// flush triggers rotation deterministically.
final active = _activeFile(dir);
active.writeAsStringSync('x' * (kLogMaxFileBytes + 1));
log.info('trigger');
await log.flush();
// After rotation a `.1` file exists.
expect(File(p.join(dir.path, 'zapstore.log.1')).existsSync(), isTrue);
});
test('keeps at most kLogMaxRotations rotated files', () async {
final (:log, :dir) = await _newService();
// Manually create rotations 1..kLogMaxRotations to simulate prior history.
for (var i = 1; i <= kLogMaxRotations; i++) {
File(p.join(dir.path, 'zapstore.log.$i'))
.writeAsStringSync('rotation $i');
}
// Pre-fill active over the threshold.
_activeFile(dir).writeAsStringSync('x' * (kLogMaxFileBytes + 1));
log.info('trigger');
await log.flush();
// The oldest (.kLogMaxRotations) must be gone (its content was
// 'rotation $kLogMaxRotations'); .1 should now contain the
// pre-fill bytes.
final newest = File(p.join(dir.path, 'zapstore.log.1'));
expect(newest.existsSync(), isTrue);
// No .{kLogMaxRotations+1}.
expect(
File(p.join(dir.path, 'zapstore.log.${kLogMaxRotations + 1}'))
.existsSync(),
isFalse);
// Rotations .2..N are filled from older content; not asserting
// exact mapping, but their count must not exceed the cap.
var rotated = 0;
for (var i = 1; i <= kLogMaxRotations; i++) {
if (File(p.join(dir.path, 'zapstore.log.$i')).existsSync()) rotated++;
}
expect(rotated, lessThanOrEqualTo(kLogMaxRotations));
});
});
group('Resilience', () {
test('reader skips malformed lines', () async {
final (:log, :dir) = await _newService();
log.info('good 1');
await log.flush();
// Append garbage line.
_activeFile(dir).writeAsStringSync('not json\n', mode: FileMode.append);
log.info('good 2');
await log.flush();
final tail = await log.readTail();
expect(tail.map((e) => e.msg).toList(), ['good 1', 'good 2']);
});
test('flushSync writes pending entries', () async {
final (:log, :dir) = await _newService();
log.fatal('about to die');
log.flushSync();
final lines = await _activeFile(dir).readAsLines();
expect(lines, hasLength(1));
final decoded = LogEntry.tryDecode(lines.single);
expect(decoded?.level, LogLevel.fatal);
expect(decoded?.msg, 'about to die');
});
});
group('Crash sinks (contract)', () {
// We can't easily install FlutterError.onError /
// PlatformDispatcher.onError under flutter_test without polluting
// the test runner, but every sink in lib/main.dart and
// background_update_service.dart routes to LogService.I.fatal()
// and then flushSync(). Asserting that path produces a durable
// entry is sufficient.
test('fatal entry survives flushSync after a synthetic crash', () async {
final (:log, :dir) = await _newService();
// Simulate the four sink callsites: each constructs a fatal entry
// and calls flushSync (the same pattern as _logUncaught).
void simulate({required String source, required Object error}) {
log.fatal(
'uncaught error',
tag: 'crash',
fields: {'source': source},
err: error,
stack: StackTrace.current,
);
log.flushSync();
}
simulate(source: 'flutter', error: StateError('framework boom'));
simulate(source: 'platform_dispatcher', error: 'engine boom');
simulate(source: 'zone', error: ArgumentError('zone boom'));
simulate(source: 'isolate', error: 'isolate boom');
final lines = await _activeFile(dir).readAsLines();
expect(lines.length, 4);
final entries = lines.map(LogEntry.tryDecode).toList();
expect(entries.every((e) => e?.level == LogLevel.fatal), isTrue);
expect(
entries.map((e) => e!.fields!['source']).toSet(),
{'flutter', 'platform_dispatcher', 'zone', 'isolate'},
);
});
});
group('Stress', () {
test('1000 entries do not block when batched', () async {
final (:log, :dir) = await _newService();
// ignore: unused_local_variable
final _ = dir;
final stopwatch = Stopwatch()..start();
for (var i = 0; i < 1000; i++) {
log.info('msg $i', tag: 'stress', fields: {'i': i});
}
// log() itself must be cheap and fully synchronous from the
// caller's POV — the disk write happens on a microtask.
stopwatch.stop();
// Generous bound; failure here means we accidentally introduced
// a synchronous file write or an unbounded buffer copy.
expect(stopwatch.elapsedMilliseconds, lessThan(500),
reason: 'log() must be non-blocking');
// Drain the flush.
await log.flush();
final ring = log.ringSnapshot();
// Ring is bounded.
expect(ring.length, kLogRingBufferSize);
// All entries reach disk (modulo rotation, which won't fire at
// these sizes).
final tail = await log.readTail(max: 2000);
expect(tail.length, 1000);
});
});
group('Concurrency', () {
test('many overlapping flushes never corrupt a line', () async {
final (:log, :dir) = await _newService();
// Fire many small batches in parallel — each `log()` schedules a
// microtask flush, and we await them all together.
final futures = <Future<void>>[];
for (var i = 0; i < 50; i++) {
log.info('parallel $i', tag: 'concurrency');
futures.add(log.flush());
}
await Future.wait(futures);
await log.flush();
// Every line on disk must be a fully-formed JSON record.
final lines = await _activeFile(dir).readAsLines();
expect(lines.length, 50);
for (final l in lines) {
final decoded = LogEntry.tryDecode(l);
expect(decoded, isNotNull, reason: 'line corrupted: $l');
expect(decoded!.tag, 'concurrency');
}
});
});
group('LogEntry codec', () {
test('round-trips through NDJSON', () {
final entry = LogEntry(
ts: DateTime.utc(2026, 1, 2, 3, 4, 5, 6),
level: LogLevel.warn,
tag: 'codec',
msg: 'hi',
fields: {'a': 1, 'b': 'x'},
err: 'boom',
stack: 'at foo()',
isolate: 'main',
);
final decoded = LogEntry.tryDecode(entry.toJsonLine());
expect(decoded, isNotNull);
expect(decoded!.ts.toIso8601String(), entry.ts.toIso8601String());
expect(decoded.level, entry.level);
expect(decoded.tag, entry.tag);
expect(decoded.fields, entry.fields);
expect(decoded.err, entry.err);
expect(decoded.stack, entry.stack);
expect(decoded.isolate, entry.isolate);
});
test('rejects malformed input', () {
expect(LogEntry.tryDecode(''), isNull);
expect(LogEntry.tryDecode('not json'), isNull);
expect(LogEntry.tryDecode('{}'), isNull);
expect(LogEntry.tryDecode('{"ts":"nope","level":"info"}'), isNull);
});
});
}