From 4c502df57395d35d531fda6be23cde16da7dc7ec Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 30 Mar 2026 22:03:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(logging):=20=EB=A1=9C=EC=BB=AC=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EB=A1=9C=EA=B1=B0=20=EA=B5=AC=ED=98=84=20(?= =?UTF-8?q?=EC=98=A4=ED=94=84=EB=9D=BC=EC=9D=B8=20=ED=81=AC=EB=9E=98?= =?UTF-8?q?=EC=8B=9C=20=EB=A6=AC=ED=8F=AC=ED=8C=85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ErrorLogger: 파일 기반 JSONL 로깅, 1MB 로테이션, 내보내기 - error_logger_zone: FlutterError.onError + runZonedGuarded - main.dart: setupErrorHandling()으로 앱 래핑 - GetIt에 ErrorLogger 등록 --- lib/main.dart | 9 +- lib/src/core/di/service_locator.dart | 4 + lib/src/core/logging/error_logger.dart | 148 ++++++++++++++++++++ lib/src/core/logging/error_logger_zone.dart | 40 ++++++ 4 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 lib/src/core/logging/error_logger.dart create mode 100644 lib/src/core/logging/error_logger_zone.dart diff --git a/lib/main.dart b/lib/main.dart index 365489a..1dc999a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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()); + }); } diff --git a/lib/src/core/di/service_locator.dart b/lib/src/core/di/service_locator.dart index 9bb2d6a..5b12670 100644 --- a/lib/src/core/di/service_locator.dart +++ b/lib/src/core/di/service_locator.dart @@ -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.instance); + // IAP 서비스 (싱글톤 등록) sl.registerLazySingleton(() => IAPService.createInstance()); diff --git a/lib/src/core/logging/error_logger.dart b/lib/src/core/logging/error_logger.dart new file mode 100644 index 0000000..e941ae9 --- /dev/null +++ b/lib/src/core/logging/error_logger.dart @@ -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 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 log( + Object error, { + StackTrace? stackTrace, + String? context, + }) async { + final file = _logFile; + if (file == null) return; + + final entry = { + '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>> recentErrors({int count = 20}) async { + final file = _logFile; + if (file == null || !file.existsSync()) return []; + + try { + final lines = await file.readAsLines(); + final entries = >[]; + // 역순(최신 먼저)으로 파싱 + 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); + } catch (_) { + // 손상된 라인(corrupted line) 건너뜀 + } + } + return entries; + } catch (e) { + debugPrint('[ErrorLogger] 로그 읽기 실패: $e'); + return []; + } + } + + /// 로그 파일 내보내기(export) — 사용자 문의 시 첨부용 + /// + /// 전체 로그 내용을 문자열로 반환합니다. + Future 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 _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'); + } +} diff --git a/lib/src/core/logging/error_logger_zone.dart b/lib/src/core/logging/error_logger_zone.dart new file mode 100644 index 0000000..77fcbe2 --- /dev/null +++ b/lib/src/core/logging/error_logger_zone.dart @@ -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 setupErrorHandling(Future 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); + }, + ); +}