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