Files
TPSchedule/mobile/lib/services/diagnostic_log.dart
T

201 lines
5.7 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import '../app/app_version.dart';
/// Small, structured breadcrumbs. Never accepts raw URLs, HTML or exceptions.
class DiagnosticLog {
DiagnosticLog({DateTime Function()? clock}) : clock = clock ?? DateTime.now;
static final instance = DiagnosticLog();
static const maxBytes = 256 * 1024;
static const _keys = {
'school',
'phase',
'page',
'count',
'term',
'type',
'status',
'code',
'version',
'result',
'row',
'column',
};
final DateTime Function() clock;
final _entries = <Map<String, dynamic>>[];
File? _file;
Future<void> _writes = Future.value();
Timer? _timer;
Map<String, Object> _safe(Map fields) => {
for (final e in fields.entries.take(6))
if (_keys.contains(e.key) &&
(e.value is bool ||
e.value is int ||
e.value is String &&
RegExp(r'^[A-Za-z0-9_.-]{1,40}$').hasMatch(e.value)))
e.key as String: e.value as Object,
};
void _trim() {
final now = clock().toUtc();
_entries.removeWhere((e) {
final at = DateTime.tryParse(e['at'] as String? ?? '');
return at == null ||
at.isAfter(now) ||
now.difference(at) > const Duration(minutes: 10);
});
if (_entries.length > 500) _entries.removeRange(0, _entries.length - 500);
}
void add(String event, {Map<String, Object?> fields = const {}}) {
if (!RegExp(r'^[a-z_]{1,64}$').hasMatch(event)) return;
_trim();
final safe = _safe(fields);
if (_entries.isNotEmpty) {
final last = _entries.last;
final at = DateTime.tryParse(last['at'] as String? ?? '');
final previous = Map<String, dynamic>.from(last)
..remove('at')
..remove('event');
if (last['event'] == event &&
jsonEncode(previous) == jsonEncode(safe) &&
at != null &&
clock().toUtc().difference(at) < const Duration(seconds: 3)) {
return;
}
}
_entries.add({
'at': clock().toUtc().toIso8601String(),
'event': event,
...safe,
});
_trim();
unawaited(flush());
}
void navigation(String school, String phase, String url) {
final uri = Uri.tryParse(url);
final page = uri == null
? 'unknown'
: uri.host == 'idas.uestc.edu.cn'
? 'identity'
: uri.host == 'yjsjy.uestc.edu.cn'
? uri.path.contains('xskbcx')
? 'timetable'
: uri.path.contains('kskcxx')
? 'exams'
: 'graduate_portal'
: uri.host == 'eams.uestc.edu.cn'
? uri.path.contains('stdExamTable')
? 'exams'
: uri.path.contains('courseTable')
? 'timetable'
: 'undergraduate_portal'
: 'external';
add('navigation_$phase', fields: {'school': school, 'page': page});
}
void resourceFailure(
String school,
String? url, {
required String code,
int? status,
bool? mainFrame,
}) {
final uri = Uri.tryParse(url ?? '');
final path = uri?.path.toLowerCase() ?? '';
final kind = path.endsWith('.js')
? 'script'
: path.endsWith('.css')
? 'stylesheet'
: RegExp(r'\.(png|jpg|jpeg|gif|webp|svg|ico)$').hasMatch(path)
? 'image'
: 'document_or_request';
add(
'school_resource_failed',
fields: {
'school': school,
'phase': mainFrame == null
? 'unknown'
: mainFrame
? 'main'
: 'subresource',
'page': kind,
'code': code,
'status': status,
'type': uri?.scheme == 'http'
? 'http'
: uri?.scheme == 'https'
? 'https'
: 'other',
},
);
}
Future<void> attach(Directory directory) async {
// Retention and export use the same bounded, sanitized entries.
final file = File('${directory.path}/diagnostics-recent.json');
try {
if (await file.exists() && await file.length() <= maxBytes) {
final saved = jsonDecode(await file.readAsString());
if (saved is List) {
final restored = <Map<String, dynamic>>[];
for (final row in saved.take(500)) {
if (row is Map &&
row['at'] is String &&
row['event'] is String &&
RegExp(r'^[a-z_]{1,64}$').hasMatch(row['event'])) {
restored.add({
'at': row['at'],
'event': row['event'],
..._safe(row),
});
}
}
_entries.insertAll(0, restored);
}
}
} catch (_) {
/* Diagnostics must never block startup or feedback. */
}
_file = file;
_timer?.cancel();
_timer = Timer.periodic(
const Duration(minutes: 1),
(_) => unawaited(flush()),
);
await flush();
}
Future<void> flush() {
_trim();
final file = _file;
if (file == null) return Future.value();
final text = jsonEncode(_entries);
_writes = _writes.then((_) async {
try {
await file.writeAsString(text, flush: true);
} catch (_) {}
});
return _writes;
}
Future<Uint8List> export() async {
_trim();
await flush();
final header =
'TPSchedule diagnostic log\nApp: TPSchedule $appVersion+$appBuildNumber\n'
'Exported at: ${clock().toUtc().toIso8601String()}\n'
'Window: last 10 minutes (UTC timestamps)\n'
'Up to 500 recent events; no passwords, cookies, HTML or feedback text.\n';
return Uint8List.fromList(
utf8.encode('$header${_entries.map(jsonEncode).join('\n')}\n'),
);
}
void dispose() => _timer?.cancel();
}