Skip to content

Instantly share code, notes, and snippets.

@PlugFox
Last active March 2, 2024 09:23
Show Gist options
  • Save PlugFox/93068ea34b43a3c2899eceed43f76a45 to your computer and use it in GitHub Desktop.
Save PlugFox/93068ea34b43a3c2899eceed43f76a45 to your computer and use it in GitHub Desktop.
Log collector and application initialization with dependencies
/// Dependencies
abstract interface class Dependencies {
/// The state from the closest instance of this class.
factory Dependencies.of(BuildContext context) => InheritedDependencies.of(context);
/// Database
abstract final Database database;
}
final class $MutableDependencies implements Dependencies {
$MutableDependencies() : context = <String, Object?>{};
/// Initialization context
final Map<Object?, Object?> context;
@override
late Database database;
Dependencies freeze() => _$ImmutableDependencies(
database: database,
);
}
final class _$ImmutableDependencies implements Dependencies {
_$ImmutableDependencies({
required this.database,
});
@override
final Database database;
}
/// Initializes the app and returns a [Dependencies] object
Future<Dependencies> $initializeDependencies({
void Function(int progress, String message)? onProgress,
}) async {
final dependencies = $MutableDependencies();
final totalSteps = _initializationSteps.length;
var currentStep = 0;
for (final step in _initializationSteps.entries) {
currentStep++;
final percent = (currentStep * 100 ~/ totalSteps).clamp(0, 100);
onProgress?.call(percent, step.key);
l.v6('Initialization | $currentStep/$totalSteps ($percent%) | "${step.key}"');
await step.value(dependencies);
}
return dependencies.freeze();
}
typedef _InitializationStep = FutureOr<void> Function($MutableDependencies dependencies);
final Map<String, _InitializationStep> _initializationSteps = <String, _InitializationStep>{
'Platform pre-initialization': (_) => $platformInitialization(),
'Initializing the database': (dependencies) => dependencies.database = Database.lazy(),
'Shrink database': (dependencies) async {
if (!Config.environment.isProduction) {
await dependencies.database.transaction(() async {
final log = await (dependencies.database.select<Log, LogData>(dependencies.database.log)
..orderBy([(tbl) => OrderingTerm(expression: tbl.id, mode: OrderingMode.desc)])
..limit(1, offset: 1000))
.getSingleOrNull();
if (log != null) {
await (dependencies.database.delete(dependencies.database.log)
..where((tbl) => tbl.time.isSmallerOrEqualValue(log.time)))
.go();
}
});
}
if (DateTime.now().second % 10 == 0) await dependencies.database.customStatement('VACUUM;');
},
/* ... */
'Collect logs': (dependencies) async {
if (Config.environment.isProduction) return;
await (dependencies.database.select<Log, LogData>(dependencies.database.log)
..orderBy([(tbl) => OrderingTerm(expression: tbl.time, mode: OrderingMode.desc)])
..limit(LogBuffer.bufferLimit))
.get()
.then<List<LogMessage>>((logs) => logs
.map((l) => l.stack != null
? LogMessageWithStackTrace(
date: DateTime.fromMillisecondsSinceEpoch(l.time * 1000),
level: LogLevel.fromValue(l.level),
message: l.message,
stackTrace: StackTrace.fromString(l.stack!))
: LogMessage(
date: DateTime.fromMillisecondsSinceEpoch(l.time * 1000),
level: LogLevel.fromValue(l.level),
message: l.message,
))
.toList())
.then<void>(LogBuffer.instance.addAll);
l.bufferTime(const Duration(seconds: 1)).where((logs) => logs.isNotEmpty).listen(LogBuffer.instance.addAll);
l
.map<LogCompanion>((log) => LogCompanion.insert(
level: log.level.level,
message: log.message.toString(),
time: Value<int>(log.date.millisecondsSinceEpoch ~/ 1000),
stack: Value<String?>(switch (log) { LogMessageWithStackTrace l => l.stackTrace.toString(), _ => null }),
))
.bufferTime(const Duration(seconds: 15))
.where((logs) => logs.isNotEmpty)
.listen(
(logs) => dependencies.database.batch((batch) => batch.insertAll(dependencies.database.log, logs)).ignore(),
cancelOnError: false,
);
};
-- Logs table
CREATE TABLE IF NOT EXISTS log (
-- req Unique identifier of the log
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
-- Time is the timestamp (in seconds) of the log message
time INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
-- Level is the severity level (a value between 0 and 6)
level INTEGER NOT NULL,
-- req Message is the log message or error associated with this log event
message TEXT NOT NULL,
-- StackTrace a stack trace associated with this log event
stack TEXT
) STRICT;
-- Indexes
CREATE INDEX IF NOT EXISTS log_time_idx ON log (time);
CREATE INDEX IF NOT EXISTS log_level_idx ON log (level);
/*
-- Logs search table
CREATE TABLE IF NOT EXISTS log_prefix (
-- req Prefix (first 3 chars of word, lowercased)
prefix TEXT NOT NULL, -- CHECK(length(prefix) = 3)
-- req Unique identifier
log_id INTEGER NOT NULL,
-- req Word (3 or more chars, lowercased)
word TEXT NOT NULL,
-- req Word's length
len INTEGER NOT NULL,
-- Composite primary key
PRIMARY KEY (prefix, log_id, word),
-- Foreign keys
FOREIGN KEY (log_id)
REFERENCES log (id)
ON UPDATE CASCADE
ON DELETE CASCADE
) STRICT;
-- Indexes
CREATE INDEX IF NOT EXISTS log_prefix_prefix_idx ON log_prefix (prefix);
CREATE INDEX IF NOT EXISTS log_prefix_log_id_idx ON log_prefix (log_id);
CREATE INDEX IF NOT EXISTS log_prefix_len_idx ON log_prefix (len);
*/
import 'dart:collection' show Queue;
import 'package:flutter/foundation.dart' show ChangeNotifier;
import 'package:l/l.dart';
/// LogBuffer Singleton class
class LogBuffer with ChangeNotifier {
static final LogBuffer _internalSingleton = LogBuffer._internal();
static LogBuffer get instance => _internalSingleton;
LogBuffer._internal();
static const int bufferLimit = 10000;
final Queue<LogMessage> _queue = Queue<LogMessage>();
/// Get the logs
Iterable<LogMessage> get logs => _queue;
/// Clear the logs
void clear() {
_queue.clear();
notifyListeners();
}
/// Add a log to the buffer
void add(LogMessage log) {
if (_queue.length >= bufferLimit) _queue.removeFirst();
_queue.add(log);
notifyListeners();
}
/// Add a list of logs to the buffer
void addAll(List<LogMessage> logs) {
logs = logs.take(bufferLimit).toList();
if (_queue.length + logs.length >= bufferLimit) {
final toRemove = _queue.length + logs.length - bufferLimit;
for (var i = 0; i < toRemove; i++) _queue.removeFirst();
}
_queue.addAll(logs);
notifyListeners();
}
@override
void dispose() {
_queue.clear();
super.dispose();
}
}
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:l/l.dart';
import 'util.dart';
import 'log_buffer.dart';
/// {@template logs_screen}
/// LogsScreen widget.
/// {@endtemplate}
class LogsScreen extends StatelessWidget {
/// {@macro logs_screen}
const LogsScreen({super.key});
/// Show the logs screen
static Future<void> show(BuildContext context) => Navigator.of(context, rootNavigator: true).push<void>(
MaterialPageRoute<void>(
builder: (context) => const LogsScreen(),
),
);
@override
Widget build(BuildContext context) => const Scaffold(body: _LogsList());
}
class _LogsList extends StatefulWidget {
const _LogsList();
@override
State<_LogsList> createState() => _LogsListState();
}
/// State for widget _LogsList.
class _LogsListState extends State<_LogsList> {
final TextEditingController _controller = TextEditingController();
final LogBuffer buffer = LogBuffer.instance;
late List<LogMessage> logs, filteredLogs;
@override
void initState() {
super.initState();
buffer.addListener(_onLogUpdated);
_controller.addListener(_filter);
_onLogUpdated();
}
@override
void dispose() {
buffer.removeListener(_onLogUpdated);
_controller.removeListener(_filter);
super.dispose();
}
void _onLogUpdated() {
logs = buffer.logs.toList();
_filter();
}
Future<void> _filter() async {
final search = _controller.text.toLowerCase();
final stopwatch = Stopwatch()..start();
final buffer = logs.toList();
try {
LogMessage log;
var pos = 0;
for (var i = 0; i < buffer.length; i++) {
if (stopwatch.elapsedMilliseconds > 8) await Future<void>.delayed(Duration.zero);
log = logs[i];
if (log.message.toString().toLowerCase().contains(search)) {
buffer[pos] = log;
pos++;
}
}
filteredLogs = buffer..length = pos;
} finally {
stopwatch.stop();
}
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) => CustomScrollView(
slivers: <Widget>[
SliverAppBar(
title: Text('Logs (${filteredLogs.length})'),
actions: <Widget>[
IconButton(icon: const Icon(Icons.delete), onPressed: () => buffer.clear()),
const SizedBox(width: 16),
],
floating: true,
pinned: MediaQuery.of(context).size.height > 600,
bottom: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Center(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Search',
prefixIcon: Icon(Icons.search),
),
),
),
),
),
),
if (filteredLogs.isEmpty)
const SliverFillRemaining(
child: Center(
child: Text('No logs found'),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _LogTile(filteredLogs[index], key: ObjectKey(filteredLogs[index])),
childCount: filteredLogs.length,
),
),
],
);
}
/// {@template logs_screen}
/// _LogTile widget.
/// {@endtemplate}
class _LogTile extends StatelessWidget {
/// {@macro logs_screen}
const _LogTile(this.log, {super.key});
final LogMessage log;
@override
Widget build(BuildContext context) => Column(
children: [
ListTile(
title: Text(log.message.toString()),
subtitle: Text(log.date.format()),
leading: _LogIcon(log.level),
dense: true,
trailing: IconButton(
icon: const Icon(Icons.copy),
onPressed: () => Clipboard.setData(
ClipboardData(
text: switch (log) {
LogMessageWithStackTrace log => '${log.message}\n${log.stackTrace}',
_ => '${log.message}'
},
),
),
),
),
const Divider(height: 1),
],
);
}
class _LogIcon extends StatelessWidget {
const _LogIcon(this.level);
final LogLevel level;
@override
Widget build(BuildContext context) => level.when<Widget>(
debug: () => const Icon(Icons.bug_report, color: Colors.indigo),
info: () => const Icon(Icons.info, color: Colors.blue),
warning: () => const Icon(Icons.warning, color: Colors.orange),
error: () => const Icon(Icons.error, color: Colors.red),
shout: () => const Icon(Icons.campaign, color: Colors.red),
v: () => const Icon(Icons.looks_one, color: Colors.grey),
vv: () => const Icon(Icons.looks_two, color: Colors.grey),
vvv: () => const Icon(Icons.looks_3, color: Colors.grey),
vvvv: () => const Icon(Icons.looks_4, color: Colors.grey),
vvvvv: () => const Icon(Icons.looks_5, color: Colors.grey),
vvvvvv: () => const Icon(Icons.looks_6, color: Colors.grey),
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment