Files
Qlyra/lib/core/utils/debug_session_log.dart
T
sevenhill f74057ce9b
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Rebrand application as Qlyra
2026-08-30 13:16:17 +03:00

411 lines
12 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../protocol/opcode_map.dart';
import 'format.dart';
import 'log_redact.dart';
class DebugExportFile {
final String name;
final String content;
DebugExportFile(this.name, this.content);
}
class _LogEntry {
final int opcode;
final int seq;
final DateTime requestTime;
final dynamic request;
DateTime? responseTime;
int? cmd;
dynamic response;
String? error;
_LogEntry({
required this.opcode,
required this.seq,
required this.requestTime,
required this.request,
});
Map<String, dynamic> toJson() => {
'opcode': opcode,
'seq': seq,
'requestTime': requestTime.toIso8601String(),
'request': request,
if (responseTime != null) 'responseTime': responseTime!.toIso8601String(),
if (cmd != null) 'cmd': cmd,
if (response != null) 'response': response,
if (error != null) 'error': error,
};
static _LogEntry fromJson(Map data) {
final entry = _LogEntry(
opcode: (data['opcode'] as num?)?.toInt() ?? 0,
seq: (data['seq'] as num?)?.toInt() ?? 0,
requestTime:
DateTime.tryParse(data['requestTime']?.toString() ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
request: data['request'],
);
final rt = data['responseTime']?.toString();
if (rt != null) entry.responseTime = DateTime.tryParse(rt);
entry.cmd = (data['cmd'] as num?)?.toInt();
entry.response = data['response'];
entry.error = data['error']?.toString();
return entry;
}
}
class _SessionData {
final DateTime startedAt;
final List<_LogEntry> entries;
final bool truncated;
final List<String> logLines;
final bool logsTruncated;
_SessionData({
required this.startedAt,
required this.entries,
this.truncated = false,
this.logLines = const [],
this.logsTruncated = false,
});
static _SessionData fromJson(Map data) {
final list = data['entries'];
final entries = <_LogEntry>[];
if (list is List) {
for (final e in list) {
if (e is Map) entries.add(_LogEntry.fromJson(e));
}
}
final rawLogs = data['logs'];
final logLines = <String>[];
if (rawLogs is List) {
for (final l in rawLogs) {
logLines.add(l.toString());
}
}
return _SessionData(
startedAt:
DateTime.tryParse(data['startedAt']?.toString() ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
entries: entries,
truncated: data['truncated'] == true,
logLines: logLines,
logsTruncated: data['logsTruncated'] == true,
);
}
}
class DebugSessionLog {
DebugSessionLog._();
static final DebugSessionLog instance = DebugSessionLog._();
static const Duration _retention = Duration(hours: 24);
static const int _maxStoredSessions = 30;
static const int _maxEntriesPerSession = 2000;
static const int _maxLogLinesPerSession = 5000;
static const Duration _flushDebounce = Duration(seconds: 3);
static final RegExp _ansiEscape = RegExp(r'\x1B\[[0-9;]*m');
Directory? _dir;
File? _currentFile;
DateTime? _currentStart;
final List<_LogEntry> _entries = [];
final List<String> _logLines = [];
bool _truncated = false;
bool _logsTruncated = false;
bool _initialized = false;
bool _dirty = false;
Timer? _flushTimer;
Future<void> init() async {
if (_initialized) return;
_initialized = true;
_currentStart = DateTime.now();
try {
final base = await getApplicationSupportDirectory();
final dir = Directory('${base.path}/debug_sessions');
if (!await dir.exists()) await dir.create(recursive: true);
_dir = dir;
await _rotate();
_currentFile = File(
'${dir.path}/session_${_currentStart!.millisecondsSinceEpoch}.json',
);
if (_dirty) _scheduleFlush();
} catch (_) {
_dir = null;
_currentFile = null;
}
}
void recordLogLine(String line) {
final clean = line.replaceAll(_ansiEscape, '');
_logLines.add(clean);
if (_logLines.length > _maxLogLinesPerSession) {
_logLines.removeRange(0, _logLines.length - _maxLogLinesPerSession);
_logsTruncated = true;
}
_scheduleFlush();
}
void recordRequest(int opcode, int seq, dynamic payload) {
_entries.add(
_LogEntry(
opcode: opcode,
seq: seq,
requestTime: DateTime.now(),
request: redactForLog(payload),
),
);
if (_entries.length > _maxEntriesPerSession) {
_entries.removeRange(0, _entries.length - _maxEntriesPerSession);
_truncated = true;
}
_scheduleFlush();
}
void recordResponse(int seq, int cmd, dynamic payload) {
final entry = _findPending(seq);
if (entry == null) return;
entry.responseTime = DateTime.now();
entry.cmd = cmd;
entry.response = redactForLog(payload);
_scheduleFlush();
}
void recordError(int seq, Object error) {
final entry = _findPending(seq);
if (entry == null) return;
entry.responseTime = DateTime.now();
entry.error = error.toString();
_scheduleFlush();
}
_LogEntry? _findPending(int seq) {
for (var i = _entries.length - 1; i >= 0; i--) {
final entry = _entries[i];
if (entry.seq == seq &&
entry.responseTime == null &&
entry.error == null) {
return entry;
}
}
return null;
}
void _scheduleFlush() {
_dirty = true;
if (_currentFile == null) return;
_flushTimer ??= Timer(_flushDebounce, () {
_flushTimer = null;
_flush();
});
}
Future<void> _flush() async {
final file = _currentFile;
if (file == null || !_dirty) return;
_dirty = false;
try {
final data = jsonEncode({
'startedAt': _currentStart?.toIso8601String(),
'truncated': _truncated,
'entries': _entries.map((e) => e.toJson()).toList(),
'logsTruncated': _logsTruncated,
'logs': List.of(_logLines),
});
await file.writeAsString(data);
} catch (_) {}
}
Future<void> flushNow() async {
_flushTimer?.cancel();
_flushTimer = null;
await _flush();
}
Future<void> _rotate() async {
final files = await _sessionFiles();
final cutoff = DateTime.now().subtract(_retention).millisecondsSinceEpoch;
final stale = <File>[];
final fresh = <File>[];
for (final file in files) {
(_startMillis(file) < cutoff ? stale : fresh).add(file);
}
const keep = _maxStoredSessions - 1;
if (fresh.length > keep) {
stale.addAll(fresh.take(fresh.length - keep));
}
for (final file in stale) {
try {
await file.delete();
} catch (_) {}
}
}
Future<List<File>> _sessionFiles() async {
final dir = _dir;
if (dir == null) return [];
final entries = await dir.list().toList();
final files = entries.whereType<File>().where((f) {
final name = f.uri.pathSegments.last;
return name.startsWith('session_') && name.endsWith('.json');
}).toList();
files.sort((a, b) => _startMillis(a).compareTo(_startMillis(b)));
return files;
}
int _startMillis(File file) {
final name = file.uri.pathSegments.last;
final digits = name.substring(
'session_'.length,
name.length - '.json'.length,
);
return int.tryParse(digits) ?? 0;
}
Future<List<DebugExportFile>?> buildExportFiles({String? endpoint}) async {
final cutoff = DateTime.now().subtract(_retention);
final sessions = <_SessionData>[];
final dir = _dir;
if (dir != null) {
for (final file in await _sessionFiles()) {
if (_currentFile != null && file.path == _currentFile!.path) continue;
try {
final decoded = jsonDecode(await file.readAsString());
if (decoded is Map) {
final session = _SessionData.fromJson(decoded);
if (!session.startedAt.isBefore(cutoff)) sessions.add(session);
}
} catch (_) {}
}
}
sessions.add(
_SessionData(
startedAt: _currentStart ?? DateTime.now(),
entries: List.of(_entries),
truncated: _truncated,
logLines: List.of(_logLines),
logsTruncated: _logsTruncated,
),
);
sessions.sort((a, b) => a.startedAt.compareTo(b.startedAt));
final totalEntries = sessions.fold<int>(
0,
(sum, s) => sum + s.entries.length,
);
final totalLogs = sessions.fold<int>(
0,
(sum, s) => sum + s.logLines.length,
);
if (totalEntries == 0 && totalLogs == 0) return null;
final info = StringBuffer();
info.writeln('Qlyra — отладочный лог');
if (endpoint != null) info.writeln('Сервер: $endpoint');
info.writeln('Экспортирован: ${DateTime.now().toIso8601String()}');
info.writeln('Период: последние ${_retention.inHours} часа');
info.writeln('Заходов в приложение: ${sessions.length}');
info.writeln('Всего запросов: $totalEntries');
info.writeln('Всего строк лога: $totalLogs');
info.writeln('Скрыто: токен полностью, номер кроме первых 3 символов');
final files = <DebugExportFile>[DebugExportFile('info.txt', '$info')];
for (var s = 0; s < sessions.length; s++) {
final session = sessions[s];
final name =
'session_${(s + 1).toString().padLeft(2, '0')}_'
'${formatFileStamp(session.startedAt)}.txt';
files.add(DebugExportFile(name, _buildSessionText(s + 1, session)));
}
return files;
}
String _buildSessionText(int index, _SessionData session) {
final buffer = StringBuffer();
buffer.writeln('==================================================');
buffer.writeln('ЗАХОД #$index${session.startedAt.toIso8601String()}');
buffer.writeln(
'запросов: ${session.entries.length}'
'${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}'
' · строк лога: ${session.logLines.length}'
'${session.logsTruncated ? ' (обрезано до $_maxLogLinesPerSession)' : ''}',
);
buffer.writeln('==================================================');
buffer.writeln();
buffer.writeln('----- ЛОГИ ПРИЛОЖЕНИЯ -----');
if (session.logLines.isEmpty) {
buffer.writeln('(пусто)');
} else {
for (final line in session.logLines) {
buffer.writeln(line);
}
}
buffer.writeln();
buffer.writeln('----- ЗАПРОСЫ -----');
if (session.entries.isEmpty) {
buffer.writeln('(пусто)');
} else {
for (var i = 0; i < session.entries.length; i++) {
_writeEntry(buffer, i + 1, session.entries[i]);
}
}
return buffer.toString();
}
void _writeEntry(StringBuffer buffer, int index, _LogEntry e) {
buffer.writeln('----- запрос #$index -----');
buffer.writeln('время: ${e.requestTime.toIso8601String()}');
buffer.writeln('opcode: ${e.opcode} (${Opcode.name(e.opcode)})');
buffer.writeln('seq: ${e.seq}');
buffer.writeln('=> payload:');
buffer.writeln(_pretty(e.request));
if (e.error != null) {
buffer.writeln('<= ошибка: ${e.error}');
} else if (e.responseTime != null) {
buffer.writeln(
'<= ответ: ${e.responseTime!.toIso8601String()} '
'cmd ${e.cmd} (${_cmdName(e.cmd)})',
);
buffer.writeln('payload:');
buffer.writeln(_pretty(e.response));
} else {
buffer.writeln('<= (ответ не получен)');
}
buffer.writeln();
}
String _pretty(dynamic value) {
try {
return const JsonEncoder.withIndent(' ').convert(value);
} catch (_) {
return value.toString();
}
}
}
String _cmdName(int? cmd) {
switch (cmd) {
case 0:
return 'request';
case 1:
return 'ok';
case 2:
return 'notFound';
case 3:
return 'error';
default:
return 'cmd$cmd';
}
}