feat(logging): 로컬 에러 로거 구현 (오프라인 크래시 리포팅)

- ErrorLogger: 파일 기반 JSONL 로깅, 1MB 로테이션, 내보내기
- error_logger_zone: FlutterError.onError + runZonedGuarded
- main.dart: setupErrorHandling()으로 앱 래핑
- GetIt에 ErrorLogger 등록
This commit is contained in:
JiWoong Sul
2026-03-30 22:03:08 +09:00
parent 6156eef90d
commit 4c502df573
4 changed files with 198 additions and 3 deletions

View File

@@ -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() {
// 서비스 로케이터(service locator) 초기화
setupServiceLocator();
runApp(const AskiiNeverDieApp());
setupErrorHandling(() async {
// 서비스 로케이터(service locator) 초기화
setupServiceLocator();
runApp(const AskiiNeverDieApp());
});
}

View File

@@ -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());

View 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');
}
}

View 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);
},
);
}