mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Implement diagnostics
This commit is contained in:
@@ -58,3 +58,5 @@ reference/*
|
||||
.fvm/
|
||||
|
||||
bugs/
|
||||
|
||||
.cursor/skills
|
||||
+136
-25
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user