feat(logging): 로컬 에러 로거 구현 (오프라인 크래시 리포팅)
- ErrorLogger: 파일 기반 JSONL 로깅, 1MB 로테이션, 내보내기 - error_logger_zone: FlutterError.onError + runZonedGuarded - main.dart: setupErrorHandling()으로 앱 래핑 - GetIt에 ErrorLogger 등록
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import 'package:asciineverdie/src/app.dart';
|
||||
import 'package:asciineverdie/src/core/di/service_locator.dart';
|
||||
import 'package:asciineverdie/src/core/logging/error_logger_zone.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
void main() {
|
||||
setupErrorHandling(() async {
|
||||
// 서비스 로케이터(service locator) 초기화
|
||||
setupServiceLocator();
|
||||
runApp(const AskiiNeverDieApp());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:asciineverdie/src/core/di/i_ad_service.dart';
|
||||
import 'package:asciineverdie/src/core/di/i_iap_service.dart';
|
||||
import 'package:asciineverdie/src/core/infrastructure/ad_service.dart';
|
||||
import 'package:asciineverdie/src/core/infrastructure/iap_service.dart';
|
||||
import 'package:asciineverdie/src/core/logging/error_logger.dart';
|
||||
|
||||
/// 전역 서비스 로케이터(service locator) 인스턴스
|
||||
final GetIt sl = GetIt.instance;
|
||||
@@ -13,6 +14,9 @@ final GetIt sl = GetIt.instance;
|
||||
/// 앱 시작 시 한 번 호출하여 모든 서비스를 등록합니다.
|
||||
/// 점진적 도입: IAPService, AdService만 먼저 등록.
|
||||
void setupServiceLocator() {
|
||||
// 에러 로거(error logger) — 이미 초기화된 싱글톤 등록
|
||||
sl.registerSingleton<ErrorLogger>(ErrorLogger.instance);
|
||||
|
||||
// IAP 서비스 (싱글톤 등록)
|
||||
sl.registerLazySingleton<IIAPService>(() => IAPService.createInstance());
|
||||
|
||||
|
||||
148
lib/src/core/logging/error_logger.dart
Normal file
148
lib/src/core/logging/error_logger.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// 로컬 파일 기반 에러 로거(error logger)
|
||||
///
|
||||
/// 네트워크 없이 에러/크래시를 로컬 파일에 기록합니다.
|
||||
/// 로그 파일 크기가 [maxLogBytes]를 초과하면 자동 로테이션(rotation)합니다.
|
||||
class ErrorLogger {
|
||||
ErrorLogger._();
|
||||
|
||||
static final ErrorLogger instance = ErrorLogger._();
|
||||
|
||||
/// 최대 로그 파일 크기 (1MB)
|
||||
static const int maxLogBytes = 1024 * 1024;
|
||||
|
||||
/// 로테이션 시 보관할 백업 파일 수
|
||||
static const int maxBackupCount = 2;
|
||||
|
||||
static const String _logFileName = 'error_log.jsonl';
|
||||
|
||||
String? _appVersion;
|
||||
File? _logFile;
|
||||
|
||||
/// 초기화(initialization) — 앱 시작 시 한 번 호출
|
||||
Future<void> init() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
_logFile = File('${dir.path}/$_logFileName');
|
||||
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
_appVersion = '${info.version}+${info.buildNumber}';
|
||||
} catch (_) {
|
||||
_appVersion = 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/// 에러 기록(log error)
|
||||
///
|
||||
/// [error] 에러 객체, [stackTrace] 스택 트레이스(stack trace),
|
||||
/// [context] 추가 맥락 정보(optional).
|
||||
Future<void> log(
|
||||
Object error, {
|
||||
StackTrace? stackTrace,
|
||||
String? context,
|
||||
}) async {
|
||||
final file = _logFile;
|
||||
if (file == null) return;
|
||||
|
||||
final entry = <String, dynamic>{
|
||||
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
||||
'error': error.toString(),
|
||||
'stackTrace': stackTrace?.toString(),
|
||||
'appVersion': _appVersion,
|
||||
'context': context,
|
||||
};
|
||||
|
||||
try {
|
||||
final line = '${jsonEncode(entry)}\n';
|
||||
await file.writeAsString(line, mode: FileMode.append, flush: true);
|
||||
await _rotateIfNeeded();
|
||||
} catch (e) {
|
||||
// 로깅 실패 시 디버그 콘솔에만 출력
|
||||
debugPrint('[ErrorLogger] 로그 기록 실패: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 최근 에러 목록 조회(recent errors)
|
||||
///
|
||||
/// 최신순으로 최대 [count]개 반환합니다.
|
||||
Future<List<Map<String, dynamic>>> recentErrors({int count = 20}) async {
|
||||
final file = _logFile;
|
||||
if (file == null || !file.existsSync()) return [];
|
||||
|
||||
try {
|
||||
final lines = await file.readAsLines();
|
||||
final entries = <Map<String, dynamic>>[];
|
||||
// 역순(최신 먼저)으로 파싱
|
||||
for (var i = lines.length - 1; i >= 0 && entries.length < count; i--) {
|
||||
final line = lines[i].trim();
|
||||
if (line.isEmpty) continue;
|
||||
try {
|
||||
entries.add(jsonDecode(line) as Map<String, dynamic>);
|
||||
} catch (_) {
|
||||
// 손상된 라인(corrupted line) 건너뜀
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
} catch (e) {
|
||||
debugPrint('[ErrorLogger] 로그 읽기 실패: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// 로그 파일 내보내기(export) — 사용자 문의 시 첨부용
|
||||
///
|
||||
/// 전체 로그 내용을 문자열로 반환합니다.
|
||||
Future<String> export() async {
|
||||
final file = _logFile;
|
||||
if (file == null || !file.existsSync()) return '';
|
||||
|
||||
try {
|
||||
return await file.readAsString();
|
||||
} catch (e) {
|
||||
debugPrint('[ErrorLogger] 로그 내보내기 실패: $e');
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// 로그 파일 경로 반환
|
||||
String? get logFilePath => _logFile?.path;
|
||||
|
||||
/// 로그 파일 크기 초과 시 로테이션(rotation) 수행
|
||||
Future<void> _rotateIfNeeded() async {
|
||||
final file = _logFile;
|
||||
if (file == null || !file.existsSync()) return;
|
||||
|
||||
final size = await file.length();
|
||||
if (size <= maxLogBytes) return;
|
||||
|
||||
final dir = file.parent.path;
|
||||
final baseName =
|
||||
_logFileName.replaceAll('.jsonl', '');
|
||||
|
||||
// 가장 오래된 백업 삭제
|
||||
final oldest = File('$dir/$baseName.$maxBackupCount.jsonl');
|
||||
if (oldest.existsSync()) {
|
||||
await oldest.delete();
|
||||
}
|
||||
|
||||
// 기존 백업 번호 증가(shift)
|
||||
for (var i = maxBackupCount - 1; i >= 1; i--) {
|
||||
final src = File('$dir/$baseName.$i.jsonl');
|
||||
if (src.existsSync()) {
|
||||
await src.rename('$dir/$baseName.${i + 1}.jsonl');
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 로그를 .1 백업으로 이동
|
||||
await file.rename('$dir/$baseName.1.jsonl');
|
||||
|
||||
// 새 빈 로그 파일 생성
|
||||
_logFile = File('$dir/$_logFileName');
|
||||
}
|
||||
}
|
||||
40
lib/src/core/logging/error_logger_zone.dart
Normal file
40
lib/src/core/logging/error_logger_zone.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/logging/error_logger.dart';
|
||||
|
||||
/// 에러 핸들링 존(error handling zone) 설정
|
||||
///
|
||||
/// [FlutterError.onError]와 [runZonedGuarded]를 조합하여
|
||||
/// 앱 전체의 미처리 에러(uncaught error)를 [ErrorLogger]에 기록합니다.
|
||||
Future<void> setupErrorHandling(Future<void> Function() appRunner) async {
|
||||
// 위젯 바인딩(widget binding) 초기화
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// 에러 로거 초기화
|
||||
await ErrorLogger.instance.init();
|
||||
|
||||
// Flutter 프레임워크 에러(framework error) 핸들러
|
||||
FlutterError.onError = (FlutterErrorDetails details) {
|
||||
// 기본 핸들러(콘솔 출력) 유지
|
||||
FlutterError.presentError(details);
|
||||
|
||||
ErrorLogger.instance.log(
|
||||
details.exception,
|
||||
stackTrace: details.stack,
|
||||
context: details.context?.toString(),
|
||||
);
|
||||
};
|
||||
|
||||
// 비동기 에러(async error) 포함 전체 존 가드(zone guard)
|
||||
runZonedGuarded(
|
||||
() async {
|
||||
await appRunner();
|
||||
},
|
||||
(Object error, StackTrace stackTrace) {
|
||||
debugPrint('[ErrorZone] 미처리 에러: $error');
|
||||
ErrorLogger.instance.log(error, stackTrace: stackTrace);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user