diff --git a/lib/core/services/debug_log_buffer.dart b/lib/core/services/debug_log_buffer.dart new file mode 100644 index 0000000..d9870e9 --- /dev/null +++ b/lib/core/services/debug_log_buffer.dart @@ -0,0 +1,57 @@ +import 'package:flutter/foundation.dart'; + +/// `debugPrint` 출력을 메모리에 모아두는 링 버퍼. +/// +/// 분석 파이프라인은 진단에 결정적인 정보를 전부 `debugPrint`로만 뱉는다 +/// (`[FrameExtractor] 추출 완료`, `[BallDetection] 검출 N/M ... 최고 점수`, +/// `[PinZone] 소스`, `[Trajectory] 리본 소스`, `[Speed] 랜드마크/기존/채택`). +/// TestFlight 빌드에는 콘솔이 없어 이게 전부 사라지므로, 실패·성공 시점에 +/// 서버로 올릴 수 있도록 최근 [capacity]줄을 붙잡아둔다. +/// +/// 내부 QA 전용 — 공개 배포 전 제거 대상. +class DebugLogBuffer { + DebugLogBuffer({this.capacity = 400}); + + /// 보관할 최대 줄 수. 분석 1회가 뱉는 줄 수(수십~200여 줄)를 넉넉히 덮는다. + final int capacity; + + final List _lines = []; + + static final DebugLogBuffer instance = DebugLogBuffer(); + + static DebugPrintCallback? _wrapper; + + /// 전역 `debugPrint`를 감싸 버퍼에 적재한다. 원래 출력은 그대로 통과시킨다. + /// + /// 이미 우리 래퍼가 걸려 있으면 아무것도 하지 않는다 — 중복 호출로 래퍼가 + /// 겹겹이 쌓이면 한 줄이 여러 번 적재되고 원래 출력도 중복된다. + static void install() { + if (identical(debugPrint, _wrapper)) return; + final previous = debugPrint; + _wrapper = (String? message, {int? wrapWidth}) { + if (message != null) instance.add(message); + previous(message, wrapWidth: wrapWidth); + }; + debugPrint = _wrapper!; + } + + int get length => _lines.length; + + void add(String line) { + _lines.add(line); + if (_lines.length > capacity) { + _lines.removeRange(0, _lines.length - capacity); + } + } + + void clear() => _lines.clear(); + + /// 버퍼 내용을 하나의 문자열로. [maxChars]를 넘으면 **뒤쪽을 남긴다** — + /// 실패 직전 줄이 가장 중요하기 때문. + String dump({int maxChars = 60000}) { + final joined = _lines.join('\n'); + if (joined.length <= maxChars) return joined; + return '…(앞부분 ${joined.length - maxChars}자 생략)\n' + '${joined.substring(joined.length - maxChars)}'; + } +} diff --git a/lib/features/analysis/data/services/analysis_debug_log_service.dart b/lib/features/analysis/data/services/analysis_debug_log_service.dart new file mode 100644 index 0000000..d7b3fb5 --- /dev/null +++ b/lib/features/analysis/data/services/analysis_debug_log_service.dart @@ -0,0 +1,75 @@ +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'package:bowling_diary/core/services/debug_log_buffer.dart'; + +/// 분석 1회의 진단 결과를 Supabase `analysis_debug_logs`에 한 행으로 남긴다. +/// +/// 목적 두 가지: +/// 1. TestFlight 실패 원인 추적 — 콘솔이 없어 `debugPrint`가 전부 사라진다. +/// 2. 구속 표본 축적 — 랜드마크/기존 두 코어 값을 매 투구마다 모아야 +/// "20km/h가 맞나(스피드건 25)"를 표본 1개가 아닌 분포로 판단할 수 있다. +/// +/// 내부 QA 전용 — 공개 배포 전 테이블과 함께 제거. +class AnalysisDebugLogService { + AnalysisDebugLogService({SupabaseClient? client, DebugLogBuffer? buffer}) + : _client = client ?? Supabase.instance.client, + _buffer = buffer ?? DebugLogBuffer.instance; + + final SupabaseClient _client; + final DebugLogBuffer _buffer; + + static const String tableName = 'analysis_debug_logs'; + + /// 업로드 실패가 분석 흐름을 절대 깨지 않도록 모든 예외를 삼킨다 — + /// 진단 수집은 부가 기능이고, 여기서 던지면 원인 진단 자체가 불가능해진다. + Future log({ + required String outcome, + String? stage, + Object? error, + StackTrace? stack, + Map? metrics, + }) async { + try { + final userId = _client.auth.currentUser?.id; + if (userId == null) { + debugPrint('[AnalysisDebugLog] 미로그인 — 업로드 생략'); + return; + } + await _client.from(tableName).insert({ + 'user_id': userId, + 'app_version': await _appVersion(), + 'device': _device(), + 'outcome': outcome, + 'stage': stage, + 'error': error?.toString(), + 'stack': stack?.toString(), + 'logs': _buffer.dump(), + 'metrics': metrics, + }); + debugPrint('[AnalysisDebugLog] 업로드 완료 ($outcome)'); + } catch (e) { + debugPrint('[AnalysisDebugLog] 업로드 실패(무시): $e'); + } + } + + Future _appVersion() async { + try { + final info = await PackageInfo.fromPlatform(); + return '${info.version}+${info.buildNumber}'; + } catch (e) { + return null; + } + } + + String? _device() { + try { + return '${Platform.operatingSystem} ${Platform.operatingSystemVersion}'; + } catch (e) { + return null; + } + } +} diff --git a/lib/features/analysis/presentation/pages/analysis_trim_page.dart b/lib/features/analysis/presentation/pages/analysis_trim_page.dart index b8dbf9c..c91461d 100644 --- a/lib/features/analysis/presentation/pages/analysis_trim_page.dart +++ b/lib/features/analysis/presentation/pages/analysis_trim_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:ffmpeg_kit_flutter_new/ffmpeg_kit.dart'; import 'package:flutter/material.dart'; @@ -6,6 +7,8 @@ import 'package:path_provider/path_provider.dart'; import 'package:video_player/video_player.dart'; import 'package:bowling_diary/app/theme/app_colors.dart'; import 'package:bowling_diary/app/theme/app_text_styles.dart'; +import 'package:bowling_diary/core/services/debug_log_buffer.dart'; +import 'package:bowling_diary/features/analysis/data/services/analysis_debug_log_service.dart'; import 'package:bowling_diary/features/analysis/data/services/analysis_pipeline.dart'; import 'package:bowling_diary/features/analysis/data/services/ball_detection_service.dart'; import 'package:bowling_diary/features/analysis/data/services/impact_detector_service.dart'; @@ -50,6 +53,7 @@ class _AnalysisTrimPageState extends State { double _totalSec = 0; bool _isAnalyzing = false; String? _trimmedPath; + final _debugLog = AnalysisDebugLogService(); @override void initState() { @@ -107,6 +111,11 @@ class _AnalysisTrimPageState extends State { Future _startAnalysis() async { setState(() => _isAnalyzing = true); + // 실패 시 어느 단계에서 터졌는지 QA가 바로 알 수 있게 단계를 기록한다 + // (기존에는 6개 실패 지점이 하나의 "문제가 발생했어요"로 뭉개졌다). + var stage = '영상 자르기'; + // 이번 분석이 남긴 줄만 올라가도록 직전 세션 로그를 비운다. + DebugLogBuffer.instance.clear(); try { final tempDir = await getTemporaryDirectory(); final trimmedPath = '${tempDir.path}/trimmed_${DateTime.now().millisecondsSinceEpoch}.mp4'; @@ -114,23 +123,29 @@ class _AnalysisTrimPageState extends State { '-i "${widget.videoPath}" -ss $_startSec -to $_endSec -c copy "$trimmedPath"', ); final trimRc = await trimSession.getReturnCode(); - if (trimRc == null || !trimRc.isValueSuccess()) throw Exception('영상 자르기 실패'); + if (trimRc == null || !trimRc.isValueSuccess()) { + throw Exception('영상 자르기 실패 (rc: $trimRc)\n${await _ffmpegTail(trimSession)}'); + } if (mounted) setState(() => _trimmedPath = trimmedPath); // 트림된 영상의 첫 프레임을 뽑아 레인 4코너를 자동검출한다(spec §10: // 영상별 자동검출+확인 — 저장형 캘리브레이션 프로파일 폐기). + stage = '첫 프레임 추출'; final framePath = '${tempDir.path}/lane_frame_${DateTime.now().millisecondsSinceEpoch}.jpg'; final frameSession = await FFmpegKit.execute( '-i "$trimmedPath" -frames:v 1 -q:v 3 "$framePath"', ); final frameRc = await frameSession.getReturnCode(); - if (frameRc == null || !frameRc.isValueSuccess()) throw Exception('첫 프레임 추출 실패'); + if (frameRc == null || !frameRc.isValueSuccess()) { + throw Exception('첫 프레임 추출 실패 (rc: $frameRc)\n${await _ffmpegTail(frameSession)}'); + } final frameBytes = await File(framePath).readAsBytes(); final frame = img.decodeImage(frameBytes); if (frame == null) throw Exception('첫 프레임 디코딩 실패'); + stage = '레인 검출'; final detection = LaneDetectorService().detect(frame); if (!mounted) return; @@ -148,8 +163,10 @@ class _AnalysisTrimPageState extends State { return; } + stage = '호모그래피 계산'; final homography = HomographySolver.solve4Point(confirmedCorners, _laneCorners); + stage = '분석 파이프라인'; final pipeline = AnalysisPipeline( frameExtractor: VideoFrameExtractorService(), ballDetector: BallDetectionService(), @@ -159,22 +176,101 @@ class _AnalysisTrimPageState extends State { ); final analysisData = await pipeline.run(trimmedPath, homography); + // 성공 케이스도 남긴다 — 구속 두 코어 값이 매 투구 쌓여야 표본 1개가 + // 아닌 분포로 정확도를 판단할 수 있다. await하지 않는다(결과 화면 지연 방지). + unawaited(_debugLog.log( + outcome: 'success', + metrics: { + 'frames_analyzed': analysisData.framesAnalyzed, + 'fps_used': analysisData.fpsUsed, + 'speed_kmh': analysisData.speedKmh, + 'landmark_speed_kmh': analysisData.landmarkSpeedKmh, + 'legacy_speed_kmh': analysisData.legacySpeedKmh, + 'speed_source': analysisData.speedSource?.name, + 'speed_confidence': analysisData.speedConfidence, + 'speed_failure': analysisData.speedFailure?.name, + 'trajectory_source': analysisData.trajectorySource?.name, + 'trajectory_fit_rms': analysisData.trajectoryFitRms, + 'trajectory_points': analysisData.trajectory.length, + 'entry_angle_deg': analysisData.entryAngleDeg, + 'trim_start_sec': _startSec, + 'trim_end_sec': _endSec, + }, + )); + if (!mounted) return; await Navigator.pushReplacement(context, MaterialPageRoute( builder: (_) => AnalysisResultPage( analysisData: analysisData, videoPath: trimmedPath, recordedAt: DateTime.now(), ), )); - } catch (e) { - debugPrint('분석 실패: $e'); + } catch (e, st) { + debugPrint('분석 실패 [$stage]: $e\n$st'); + unawaited(_debugLog.log( + outcome: 'failure', + stage: stage, + error: e, + stack: st, + metrics: { + 'trim_start_sec': _startSec, + 'trim_end_sec': _endSec, + 'source_fps': widget.fps, + }, + )); if (!mounted) return; setState(() => _isAnalyzing = false); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('분석 중 문제가 발생했어요. 다시 시도해 주세요')), + SnackBar( + content: Text('[$stage] 단계에서 실패했어요. 다시 시도해 주세요'), + duration: const Duration(seconds: 8), + action: SnackBarAction( + label: '자세히', + onPressed: () => _showFailureDetail(stage, e, st), + ), + ), ); } } + /// ffmpeg 실패 원인은 returnCode만으론 알 수 없다 — 마지막 로그 몇 줄을 + /// 예외 메시지에 붙여 TestFlight에서도 원인이 보이게 한다. + Future _ffmpegTail(dynamic session) async { + try { + final logs = await session.getLogs(); + final lines = logs + .map((dynamic l) => l.getMessage() as String? ?? '') + .join() + .split('\n') + .where((String l) => l.trim().isNotEmpty) + .toList(); + return lines.length <= 12 ? lines.join('\n') : lines.sublist(lines.length - 12).join('\n'); + } catch (e) { + return '(로그 수집 실패: $e)'; + } + } + + /// 내부 QA용 — 실패 단계/예외/스택을 그대로 보여준다. 공개 배포 전 제거. + void _showFailureDetail(String stage, Object error, StackTrace st) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('분석 실패 — $stage'), + content: SizedBox( + width: double.maxFinite, + child: SingleChildScrollView( + child: SelectableText( + '$error\n\n$st', + style: const TextStyle(fontSize: 11, fontFamily: 'monospace'), + ), + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('닫기')), + ], + ), + ); + } + String _fmt(double seconds) { final m = (seconds ~/ 60).toString().padLeft(2, '0'); final s = (seconds % 60).toStringAsFixed(1).padLeft(4, '0'); diff --git a/lib/main.dart b/lib/main.dart index 0cb05af..84605fc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,8 +4,13 @@ import 'package:intl/date_symbol_data_local.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:bowling_diary/app/app.dart' show AppRestarter, preloadTheme; import 'package:bowling_diary/core/constants/supabase_constants.dart'; +import 'package:bowling_diary/core/services/debug_log_buffer.dart'; void main() async { + // 분석 진단 로그를 서버로 올릴 수 있게 debugPrint를 버퍼에 적재한다 + // (내부 QA 전용 — 공개 배포 전 제거). + DebugLogBuffer.install(); + final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); diff --git a/pubspec.lock b/pubspec.lock index 454db97..727dcd2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1001,7 +1001,7 @@ packages: source: hosted version: "2.2.0" package_info_plus: - dependency: transitive + dependency: "direct main" description: name: package_info_plus sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" diff --git a/pubspec.yaml b/pubspec.yaml index 8366940..d4ef43a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: bowling_diary description: "볼링 기록 & 분석 앱 - 나의 볼링 성장 일기장" publish_to: 'none' -version: 1.3.0+6 +version: 1.3.0+7 environment: sdk: ^3.5.0 @@ -50,6 +50,7 @@ dependencies: phosphor_flutter: ^2.1.0 tflite_flutter: ^0.12.1 equatable: ^2.1.0 + package_info_plus: ^9.0.1 dev_dependencies: flutter_test: @@ -91,5 +92,4 @@ flutter: - assets/ - assets/icon/ - assets/splash/ - - assets/videos/ - assets/models/ diff --git a/supabase/migrations/20260730_analysis_debug_logs.sql b/supabase/migrations/20260730_analysis_debug_logs.sql new file mode 100644 index 0000000..26cb257 --- /dev/null +++ b/supabase/migrations/20260730_analysis_debug_logs.sql @@ -0,0 +1,40 @@ +-- 분석 진단 로그 (내부 QA 전용) +-- +-- TestFlight에서는 debugPrint가 어디에도 남지 않아 분석 실패 원인과 +-- 파이프라인 중간 지표(검출률/핀존 소스/구속 두 코어 값)를 확인할 수 없다. +-- 성공/실패 양쪽 모두 한 행씩 남겨 표본을 축적한다. +-- +-- 적용 완료: 2026-07-30, 프로젝트 oofyrdipvinsgsrzqjvw. 이 파일은 기록용이며 +-- 재실행해도 안전하다(if not exists / drop policy if exists). +-- 공개 배포 전 제거 대상(QA 뱃지·진단 필드와 함께). + +create table if not exists public.analysis_debug_logs ( + id uuid primary key default gen_random_uuid(), + created_at timestamptz not null default now(), + user_id uuid not null default auth.uid() references auth.users(id) on delete cascade, + app_version text, + device text, + outcome text not null check (outcome in ('success', 'failure')), + stage text, + error text, + stack text, + logs text, + metrics jsonb +); + +create index if not exists analysis_debug_logs_user_created_idx + on public.analysis_debug_logs (user_id, created_at desc); + +alter table public.analysis_debug_logs enable row level security; + +drop policy if exists "analysis_debug_logs_insert_own" on public.analysis_debug_logs; +create policy "analysis_debug_logs_insert_own" + on public.analysis_debug_logs for insert + to authenticated + with check (user_id = auth.uid()); + +drop policy if exists "analysis_debug_logs_select_own" on public.analysis_debug_logs; +create policy "analysis_debug_logs_select_own" + on public.analysis_debug_logs for select + to authenticated + using (user_id = auth.uid()); diff --git a/test/core/services/debug_log_buffer_test.dart b/test/core/services/debug_log_buffer_test.dart new file mode 100644 index 0000000..833476c --- /dev/null +++ b/test/core/services/debug_log_buffer_test.dart @@ -0,0 +1,91 @@ +import 'package:bowling_diary/core/services/debug_log_buffer.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('DebugLogBuffer 적재', () { + test('capacity 이내면 전부 보관한다', () { + final buffer = DebugLogBuffer(capacity: 5); + for (var i = 0; i < 5; i++) { + buffer.add('line$i'); + } + expect(buffer.length, 5); + expect(buffer.dump(), 'line0\nline1\nline2\nline3\nline4'); + }); + + test('capacity를 넘으면 오래된 줄부터 버린다', () { + final buffer = DebugLogBuffer(capacity: 3); + for (var i = 0; i < 10; i++) { + buffer.add('line$i'); + } + expect(buffer.length, 3); + expect(buffer.dump(), 'line7\nline8\nline9'); + }); + + test('clear는 버퍼를 비운다', () { + final buffer = DebugLogBuffer(capacity: 3)..add('a'); + buffer.clear(); + expect(buffer.length, 0); + expect(buffer.dump(), ''); + }); + }); + + group('DebugLogBuffer.dump 절단', () { + test('maxChars 이내면 그대로 반환한다', () { + final buffer = DebugLogBuffer()..add('짧은 줄'); + expect(buffer.dump(maxChars: 100), '짧은 줄'); + }); + + test('maxChars를 넘으면 앞이 아니라 뒤를 남긴다', () { + // 실패 직전 줄이 진단에 가장 중요하므로 tail 보존이 요구사항이다. + final buffer = DebugLogBuffer(); + for (var i = 0; i < 100; i++) { + buffer.add('x' * 20); + } + buffer.add('MARKER_마지막줄'); + + final dumped = buffer.dump(maxChars: 50); + expect(dumped, contains('MARKER_마지막줄')); + expect(dumped, startsWith('…(앞부분 ')); + expect(dumped.endsWith('MARKER_마지막줄'), isTrue); + }); + }); + + group('DebugLogBuffer.install', () { + test('debugPrint 출력을 전역 인스턴스에 적재하고 원래 출력도 통과시킨다', () { + final original = debugPrint; + final passedThrough = []; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) passedThrough.add(message); + }; + + DebugLogBuffer.install(); + DebugLogBuffer.instance.clear(); + debugPrint('[Test] 진단 한 줄'); + + expect(DebugLogBuffer.instance.dump(), contains('[Test] 진단 한 줄')); + expect(passedThrough, contains('[Test] 진단 한 줄')); + + debugPrint = original; + }); + + test('중복 install해도 래퍼가 겹쳐 쌓이지 않는다', () { + final original = debugPrint; + final passedThrough = []; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) passedThrough.add(message); + }; + + DebugLogBuffer.install(); + DebugLogBuffer.install(); + DebugLogBuffer.instance.clear(); + debugPrint('중복'); + + // 래퍼가 두 겹이면 원래 출력이 2번 호출되고 버퍼에도 2줄이 쌓인다. + expect(passedThrough.where((l) => l == '중복').length, 1); + expect(DebugLogBuffer.instance.length, 1); + + debugPrint = original; + }); + }); +}