Line data Source code
1 : import 'dart:async';
2 : import 'dart:convert';
3 : import 'dart:io';
4 :
5 : import 'package:flutter/widgets.dart';
6 : import 'package:logging/logging.dart';
7 : import 'package:path/path.dart';
8 : import 'package:path_provider/path_provider.dart';
9 : import 'package:sqflite/sqflite.dart';
10 : import 'package:the_logger/src/abstract_logger.dart';
11 : import 'package:the_logger/src/models/models.dart';
12 :
13 : /// Database logger
14 : class DbLogger extends AbstractLogger {
15 : late final Database _database;
16 : late final Map<Level, int> _retainStrategy;
17 : int _sessionId = -1;
18 : final List<Future<void>> _pendingOperations = [];
19 :
20 2 : @override
21 : Future<void> init(Map<Level, int> retainStrategy) async {
22 2 : WidgetsFlutterBinding.ensureInitialized();
23 :
24 2 : _retainStrategy = retainStrategy;
25 :
26 4 : _database = await openDatabase(
27 2 : join(
28 2 : await getDatabasesPath(),
29 : 'logs.db',
30 : ),
31 2 : onCreate: _onCreate,
32 : version: 1,
33 : );
34 : }
35 :
36 1 : Future<void> _onCreate(Database db, int _) async {
37 1 : await db.execute(
38 : '''
39 : CREATE TABLE sessions (
40 : id INTEGER PRIMARY KEY AUTOINCREMENT,
41 : timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
42 : );
43 : ''',
44 : );
45 1 : await db.execute(
46 : '''
47 : CREATE TABLE records (
48 : id INTEGER PRIMARY KEY AUTOINCREMENT,
49 : record_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
50 : session_id INTEGER,
51 : level INTEGER,
52 : message TEXT,
53 : logger_name TEXT,
54 : error TEXT,
55 : stack_trace TEXT,
56 : time TIMESTAMP
57 : );
58 : ''',
59 : );
60 : }
61 :
62 2 : @override
63 : Future<String?> sessionStart() async {
64 6 : await _database.transaction((txn) async {
65 4 : _sessionId = await txn.rawInsert(
66 : '''INSERT INTO sessions DEFAULT VALUES;''',
67 : );
68 : });
69 :
70 2 : final cleanupFuture = Future.delayed(
71 : const Duration(milliseconds: 200),
72 2 : _cleanup,
73 : );
74 4 : _pendingOperations.add(cleanupFuture);
75 2 : unawaited(
76 2 : cleanupFuture.whenComplete(
77 6 : () => _pendingOperations.remove(cleanupFuture),
78 : ),
79 : );
80 :
81 4 : return 'new session id: $_sessionId';
82 : }
83 :
84 2 : @override
85 : void write(MaskedLogRecord record) {
86 4 : if (!_database.isOpen) return;
87 :
88 2 : unawaited(
89 4 : _database.insert(
90 : 'records',
91 2 : record.toMap(
92 2 : sessionId: _sessionId,
93 2 : mask: shouldMask,
94 : ),
95 : ),
96 : );
97 : }
98 :
99 : /// Get all logs as strings
100 1 : Future<String> getAllLogsAsString() async {
101 2 : if (!_database.isOpen) return '';
102 :
103 2 : final list = await _database.rawQuery(
104 : '''
105 : SELECT * FROM records ORDER BY record_timestamp ASC
106 : ''',
107 : );
108 :
109 2 : return list.map((element) => '$element').join('\n');
110 : }
111 :
112 : /// Get all logs as [LogRecord]s (for debug purposes only)
113 0 : Future<List<LogRecord>> getAllLogs() async {
114 0 : if (!_database.isOpen) return [];
115 :
116 0 : final list = await _database.rawQuery(
117 : '''
118 : SELECT * FROM records ORDER BY record_timestamp ASC
119 : ''',
120 : );
121 :
122 0 : return list.map(WritableLogRecord.fromMap).toList();
123 : }
124 :
125 : /// Get all logs as maps (for debug purposes only)
126 2 : Future<List<Map<String, Object?>>> getAllLogsAsMaps() async {
127 4 : if (!_database.isOpen) return [];
128 :
129 4 : return _database.rawQuery(
130 : '''
131 : SELECT * FROM records ORDER BY record_timestamp ASC
132 : ''',
133 : );
134 : }
135 :
136 : /// Write logs to archived JSON, return file path
137 1 : Future<String> writeAllLogsToJson(String filename) async {
138 2 : if (!_database.isOpen) return 'Database is not ready';
139 :
140 2 : final cursor = await _database.rawQueryCursor(
141 : '''
142 : SELECT
143 : logger_name,
144 : id,
145 : record_timestamp,
146 : session_id,
147 : level,
148 : message,
149 : error,
150 : stack_trace,
151 : time
152 : FROM records ORDER BY record_timestamp ASC
153 : ''',
154 1 : [],
155 : );
156 :
157 1 : final fileAcrhive = _FileAcrhive();
158 1 : final filePath = await fileAcrhive.open(filename);
159 :
160 1 : await fileAcrhive.writeString('{\n "logs": [\n');
161 :
162 : try {
163 : var isFirst = true;
164 1 : while (await cursor.moveNext()) {
165 1 : final row = cursor.current;
166 1 : final string = json.encode(row);
167 : final comma = isFirst ? '' : ',\n';
168 2 : await fileAcrhive.writeString('$comma $string');
169 : isFirst = false;
170 : }
171 : } finally {
172 1 : await cursor.close();
173 : }
174 :
175 1 : await fileAcrhive.writeString('\n ]\n}');
176 :
177 1 : await fileAcrhive.close();
178 :
179 : return filePath;
180 : }
181 :
182 2 : Future<void> _cleanup() async {
183 12 : if (!_database.isOpen || _sessionId < 0 || _retainStrategy.isEmpty) return;
184 :
185 6 : final levelList = _retainStrategy.entries.toList()
186 6 : ..sort((a, b) => b.key.compareTo(a.key));
187 :
188 : Level? nextLevel;
189 2 : var retain = _sessionId;
190 : final leveListWithBouds = levelList
191 2 : .map(
192 2 : (e) {
193 2 : final ret = _LevelBound(
194 2 : level: e.key,
195 : nextLevel: nextLevel,
196 2 : sessionId: e.value,
197 : );
198 2 : nextLevel = e.key;
199 :
200 : return ret;
201 : },
202 : )
203 2 : .toList()
204 2 : .reversed
205 4 : .map((e) {
206 4 : retain -= e.sessionId;
207 :
208 2 : return e.copyWith(sessionId: retain);
209 : })
210 2 : .toList();
211 :
212 4 : final where = leveListWithBouds.fold('', (previousValue, element) {
213 3 : final previous = previousValue.isNotEmpty ? '$previousValue OR ' : '';
214 2 : final next = element.nextLevel == null
215 : ? ''
216 3 : : 'AND level < ${element.nextLevel!.value}';
217 :
218 : return '''
219 6 : $previous(session_id < ${element.sessionId} AND level >= ${element.level.value} $next)
220 2 : ''';
221 : });
222 :
223 : final query =
224 : '''
225 : DELETE FROM records WHERE $where;
226 2 : ''';
227 :
228 4 : await _database.execute(query);
229 : }
230 :
231 : /// Clear logs (for debug purposes only)
232 2 : Future<void> clearAllLogs() async {
233 : const query = '''
234 : DELETE FROM records;
235 : ''';
236 :
237 4 : await _database.execute(query);
238 : }
239 :
240 : /// Whether to mask logs
241 : bool shouldMask = true;
242 :
243 2 : @override
244 : Future<void> dispose() async {
245 4 : await Future.wait(_pendingOperations);
246 4 : _pendingOperations.clear();
247 4 : if (_database.isOpen) {
248 4 : await _database.close();
249 : }
250 : }
251 : }
252 :
253 : class _LevelBound {
254 2 : _LevelBound({
255 : required this.level,
256 : required this.nextLevel,
257 : required this.sessionId,
258 : });
259 : final Level level;
260 : final Level? nextLevel;
261 : final int sessionId;
262 :
263 4 : _LevelBound copyWith({int? sessionId}) => _LevelBound(
264 2 : level: level,
265 2 : nextLevel: nextLevel,
266 0 : sessionId: sessionId ?? this.sessionId,
267 : );
268 : }
269 :
270 : class _FileAcrhive {
271 1 : _FileAcrhive();
272 :
273 : IOSink? _file;
274 : final ZLibEncoder _encoder = gzip.encoder;
275 : Sink<List<int>>? _sink;
276 :
277 1 : Future<String> open(String filename) async {
278 1 : await close();
279 :
280 1 : final filePath = join(
281 2 : (await getTemporaryDirectory()).path,
282 1 : '$filename.gzip',
283 : );
284 :
285 1 : final file = File(filePath);
286 2 : await file.parent.create(recursive: true);
287 :
288 : try {
289 1 : await file.delete();
290 1 : } on FileSystemException catch (_) {}
291 :
292 2 : _file = file.openWrite();
293 :
294 4 : _sink = _encoder.startChunkedConversion(_file!);
295 :
296 : return filePath;
297 : }
298 :
299 1 : Future<void> close() async {
300 2 : _sink?.close();
301 2 : await _file?.flush();
302 : // TODO(nesquikm): idk, but tests fails when this is awaited
303 3 : unawaited(_file?.close());
304 : }
305 :
306 1 : Future<void> writeString(String string) async {
307 3 : _sink!.add(utf8.encode(string));
308 : }
309 : }
|