Skip to content

Commit 663ed06

Browse files
committed
feat(log): replay persisted logs in the viewer and flush on background
The log screen now replays the last 24 hours from the logs table into Talker's history on open, so it covers the launch that crashed instead of only the current session; lines are skipped by timestamp when memory already reaches back. App lifecycle flushes the write buffer at backgrounding, the moment a kill is most likely.
1 parent 8a8e523 commit 663ed06

3 files changed

Lines changed: 227 additions & 6 deletions

File tree

lib/app/app.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:async';
2+
13
import 'package:dpip/app/router/app_router.dart';
24
import 'package:dpip/app/router/notification_routes.dart';
35
import 'package:dpip/app/theme/app_theme.dart';
@@ -159,6 +161,12 @@ class _AppServicesHostState extends State<_AppServicesHost>
159161
// warm foreground is the reliable re-arm point. (The monitor handles the
160162
// foreground reporter + town on resume.)
161163
if (state == AppLifecycleState.resumed && _ready) _armBackground();
164+
// Going to the background is the moment the process is most likely to be
165+
// killed, and the buffered log lines are exactly what would explain why.
166+
if (state == AppLifecycleState.paused ||
167+
state == AppLifecycleState.detached) {
168+
unawaited(Log.flush());
169+
}
162170
}
163171

164172
/// Resolves the current township and starts reporting — but only when

lib/features/log/presentation/pages/log_page.dart

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1+
import 'dart:async';
2+
13
import 'package:dpip/core/logging/log.dart';
4+
import 'package:dpip/core/logging/log_store.dart';
25
import 'package:dpip/l10n/gen/app_localizations.dart';
36
import 'package:flutter/material.dart';
47
import 'package:talker_flutter/talker_flutter.dart';
58

6-
/// In-app log viewer, backed by the shared [Log] history. Reachable from the
7-
/// More tab; pushed as a full-screen route.
9+
/// In-app log viewer. Reachable from the More tab; pushed as a full-screen
10+
/// route.
811
///
9-
/// Applies a 7-day retention window on open: the in-memory history isn't
10-
/// persisted across launches and is otherwise bounded only by count, so opening
11-
/// the screen drops anything older than a week.
12+
/// Shows Talker's live view of *this* session, and on open replays the last 24
13+
/// hours from the `logs` table into it — so the screen covers the launch that
14+
/// crashed, not just the one you are looking at. The replay happens once per
15+
/// visit and is skipped when the history already reaches back that far, which
16+
/// is the common case for a session that has been running a while.
1217
class LogPage extends StatefulWidget {
1318
const LogPage({super.key});
1419

@@ -20,7 +25,31 @@ class _LogPageState extends State<LogPage> {
2025
@override
2126
void initState() {
2227
super.initState();
23-
Log.pruneOlderThan(const Duration(days: 7));
28+
unawaited(_replayPersisted());
29+
}
30+
31+
/// Pulls the persisted log into Talker's history, oldest first, so the
32+
/// screen reads in the order things happened.
33+
///
34+
/// Anything already in memory is skipped by timestamp: a session that has
35+
/// been open all day would otherwise show every line twice.
36+
Future<void> _replayPersisted() async {
37+
final store = Log.store;
38+
if (store == null) return;
39+
// Flush first, or the newest lines — the ones the user came to read — are
40+
// still sitting in the write buffer.
41+
await store.flush();
42+
final oldestInMemory = Log.talker.history.isEmpty
43+
? null
44+
: Log.talker.history.first.time;
45+
final stored = await store.recent(limit: 2000);
46+
for (final entry in stored.reversed) {
47+
if (oldestInMemory != null && !entry.time.isBefore(oldestInMemory)) {
48+
continue;
49+
}
50+
Log.talker.logCustom(_PersistedLog(entry));
51+
}
52+
if (mounted) setState(() {});
2453
}
2554

2655
@override
@@ -37,3 +66,35 @@ class _LogPageState extends State<LogPage> {
3766
);
3867
}
3968
}
69+
70+
/// A replayed line, tagged so it is visibly from an earlier session rather
71+
/// than something that just happened.
72+
class _PersistedLog extends TalkerLog {
73+
_PersistedLog(this.entry)
74+
: super(entry.message, time: entry.time, stackTrace: null);
75+
76+
final StoredLog entry;
77+
78+
@override
79+
String get title => entry.level;
80+
81+
@override
82+
AnsiPen get pen => switch (entry.level) {
83+
'error' || 'critical' => AnsiPen()..red(),
84+
'warning' => AnsiPen()..yellow(),
85+
'debug' => AnsiPen()..gray(),
86+
_ => AnsiPen()..blue(),
87+
};
88+
89+
@override
90+
String generateTextMessage({
91+
TimeFormat timeFormat = TimeFormat.timeAndSeconds,
92+
}) {
93+
return [
94+
'[${entry.level}] ${entry.time.toIso8601String()}',
95+
entry.message,
96+
?entry.error,
97+
?entry.stackTrace,
98+
].join('\n');
99+
}
100+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/// The persisted log: what it keeps, what it drops, and what it must not do.
2+
///
3+
/// Two properties matter more than the rest. It must **never throw** — a
4+
/// logger that can fail its caller turns a diagnostic into an outage, and it
5+
/// is called from error handlers where there is nowhere left to report to. And
6+
/// its retention must run on **write**, because retention that only runs when
7+
/// something reads the table is retention that never runs: nobody opens the
8+
/// log screen on the device that is filling up.
9+
library;
10+
11+
import 'package:dpip/core/logging/log_store.dart';
12+
import 'package:flutter_test/flutter_test.dart';
13+
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
14+
15+
/// A fresh database per call — sqflite hands back the same handle for a
16+
/// repeated path, and `:memory:` is a path.
17+
Future<Database> _openMemory() => databaseFactoryFfi.openDatabase(
18+
inMemoryDatabasePath,
19+
options: OpenDatabaseOptions(singleInstance: false),
20+
);
21+
22+
void main() {
23+
TestWidgetsFlutterBinding.ensureInitialized();
24+
sqfliteFfiInit();
25+
26+
var clock = DateTime.utc(2026, 8, 15, 12);
27+
28+
Future<(LogStore, Database)> makeStore({int flushAt = 64}) async {
29+
final db = await _openMemory();
30+
await LogStore.createSchema(db);
31+
return (LogStore(db, now: () => clock, flushAt: flushAt), db);
32+
}
33+
34+
StoredLog line(String message, {String level = 'info', DateTime? at}) =>
35+
StoredLog(time: at ?? clock, level: level, message: message);
36+
37+
setUp(() => clock = DateTime.utc(2026, 8, 15, 12));
38+
39+
test('a line survives a flush and reads back whole', () async {
40+
final (store, _) = await makeStore();
41+
store.add(
42+
StoredLog(
43+
time: clock,
44+
level: 'error',
45+
message: 'boom',
46+
error: 'StateError: bad',
47+
stackTrace: '#0 somewhere',
48+
),
49+
);
50+
await store.flush();
51+
52+
final stored = await store.recent();
53+
expect(stored, hasLength(1));
54+
expect(stored.single.message, 'boom');
55+
expect(stored.single.level, 'error');
56+
expect(stored.single.error, 'StateError: bad');
57+
expect(stored.single.stackTrace, '#0 somewhere');
58+
});
59+
60+
test('adding does not touch the database until a flush', () async {
61+
final (store, _) = await makeStore();
62+
store.add(line('buffered'));
63+
expect(
64+
await store.count(),
65+
0,
66+
reason: 'a log call must not cost a database round-trip',
67+
);
68+
await store.flush();
69+
expect(await store.count(), 1);
70+
});
71+
72+
test('a burst flushes itself without waiting for the timer', () async {
73+
// A reconnect loop or a stack-trace storm should not sit in memory until
74+
// the timer fires — that is the run most likely to end in a kill.
75+
final (store, _) = await makeStore(flushAt: 4);
76+
for (var i = 0; i < 4; i++) {
77+
store.add(line('line $i'));
78+
}
79+
// The flush is scheduled synchronously by the fourth `add`.
80+
await Future<void>.delayed(Duration.zero);
81+
expect(await store.count(), 4);
82+
});
83+
84+
test('anything past 24 hours is dropped, on write', () async {
85+
final (store, _) = await makeStore();
86+
store.add(line('old', at: clock.subtract(const Duration(hours: 25))));
87+
store.add(line('edge', at: clock.subtract(const Duration(hours: 23))));
88+
await store.flush();
89+
// The prune runs inside the same transaction as the insert, so a line that
90+
// is already too old never even lands.
91+
expect((await store.recent()).map((e) => e.message), ['edge']);
92+
93+
// Two hours on, `edge` has aged out too — and it is the *write* that
94+
// notices, not a reader. Nobody opens the log screen on the device that is
95+
// filling up.
96+
clock = clock.add(const Duration(hours: 2));
97+
store.add(line('new'));
98+
await store.flush();
99+
100+
final messages = (await store.recent()).map((e) => e.message).toList();
101+
expect(messages, ['new']);
102+
});
103+
104+
test('reads come back newest first', () async {
105+
final (store, _) = await makeStore();
106+
for (var i = 0; i < 3; i++) {
107+
store.add(line('line $i', at: clock.add(Duration(minutes: i))));
108+
}
109+
await store.flush();
110+
expect((await store.recent()).map((e) => e.message).toList(), [
111+
'line 2',
112+
'line 1',
113+
'line 0',
114+
]);
115+
});
116+
117+
test('a level filter narrows the read', () async {
118+
final (store, _) = await makeStore();
119+
store
120+
..add(line('fine'))
121+
..add(line('bad', level: 'error'));
122+
await store.flush();
123+
expect((await store.recent(level: 'error')).map((e) => e.message), ['bad']);
124+
});
125+
126+
test('clear empties it', () async {
127+
final (store, _) = await makeStore();
128+
store.add(line('x'));
129+
await store.flush();
130+
await store.clear();
131+
expect(await store.count(), 0);
132+
});
133+
134+
test('a closed database is survived, not propagated', () async {
135+
// The property that matters most: this is called from error handlers, and
136+
// an exception here would replace a diagnostic with a crash.
137+
final (store, db) = await makeStore();
138+
await db.close();
139+
store.add(line('after close'));
140+
await expectLater(store.flush(), completes);
141+
expect(await store.count(), 0);
142+
expect(await store.recent(), isEmpty);
143+
await expectLater(store.clear(), completes);
144+
await expectLater(store.dispose(), completes);
145+
});
146+
147+
test('flushing an empty buffer is a no-op', () async {
148+
final (store, _) = await makeStore();
149+
await expectLater(store.flush(), completes);
150+
expect(await store.count(), 0);
151+
});
152+
}

0 commit comments

Comments
 (0)