Files
social-app/docs/plans/2026-04-01-flutter-logging-system.md
T

16 KiB
Raw Blame History

Flutter 日志系统实现计划

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: 建立前端日志系统,debug 输出到 consolerelease 输出到本地文件

Architecture:

  • 日志模块放在 apps/lib/core/logging/
  • 提供 getLogger(module) 接口
  • Debug 模式:debugPrint 输出到 console
  • Release 模式:输出到 getApplicationDocumentsDirectory()/logs/app.log

Task 1: 日志模块骨架

Files:

  • Create: apps/lib/core/logging/logger.dart
  • Create: apps/lib/core/logging/log_entry.dart
  • Create: apps/lib/core/logging/log_config.dart
  • Create: apps/lib/core/logging/log_service.dart
  • Create: apps/lib/core/logging/log_file_handler.dart

Step 1: Create log_entry.dart

// apps/lib/core/logging/log_entry.dart

enum LogLevel { debug, info, warning, error }

class LogEntry {
  final DateTime timestamp;
  final LogLevel level;
  final String message;
  final String module;
  final String? funcName;
  final int? lineNo;
  final String? errorType;
  final String? stackTrace;
  final Map<String, dynamic>? extra;

  LogEntry({
    required this.timestamp,
    required this.level,
    required this.message,
    required this.module,
    this.funcName,
    this.lineNo,
    this.errorType,
    this.stackTrace,
    this.extra,
  });

  Map<String, dynamic> toJson() => {
    'timestamp': timestamp.toIso8601String(),
    'level': level.name,
    'message': message,
    'module': module,
    if (funcName != null) 'func_name': funcName,
    if (lineNo != null) 'line_no': lineNo,
    if (errorType != null) 'error_type': errorType,
    if (stackTrace != null) 'stack_trace': stackTrace,
    if (extra != null && extra!.isNotEmpty) 'extra': extra,
  };

  String toConsoleString() {
    final ts = timestamp.toIso8601String();
    final location = [
      if (funcName != null) funcName,
      if (lineNo != null) '@$lineNo',
    ].join('');
    final locationStr = location.isNotEmpty ? ' [$location]' : '';
    final errorStr = errorType != null ? ' [$errorType]' : '';
    final extraStr = extra != null && extra!.isNotEmpty ? ' $extra' : '';
    return '$ts ${level.name.toUpperCase().padRight(7)} [$module$locationStr]$errorStr $message$extraStr';
  }

  String toFileString() {
    final sb = StringBuffer();
    sb.writeln('[$timestamp] ${level.name.toUpperCase()} [$module]');
    if (funcName != null || lineNo != null) {
      sb.write('  at $funcName' ?? '');
      if (lineNo != null) sb.write(':$lineNo');
      sb.writeln();
    }
    sb.writeln('  $message');
    if (errorType != null) {
      sb.writeln('  Error: $errorType');
    }
    if (stackTrace != null) {
      sb.writeln('  StackTrace:');
      sb.writeln(stackTrace);
    }
    if (extra != null && extra!.isNotEmpty) {
      sb.writeln('  Extra: $extra');
    }
    return sb.toString();
  }
}

Step 2: Create log_config.dart

// apps/lib/core/logging/log_config.dart

enum LogOutput { console, file }

class LogConfig {
  final LogLevel minLevel;
  final LogOutput output;
  final String logFileName;
  final String logDir;

  const LogConfig({
    this.minLevel = LogLevel.debug,
    this.output = LogOutput.console,
    this.logFileName = 'app.log',
    this.logDir = 'logs',
  });

  static LogConfig forDebug() => const LogConfig(
    minLevel: LogLevel.debug,
    output: LogOutput.console,
  );

  static LogConfig forRelease() => const LogConfig(
    minLevel: LogLevel.warning,
    output: LogOutput.file,
    logFileName: 'app.log',
    logDir = 'logs',
  );
}

Step 3: Create log_file_handler.dart

// apps/lib/core/logging/log_file_handler.dart

import 'dart:io';
import 'package:path_provider/path_provider.dart';

class LogFileHandler {
  File? _file;
  IOSink? _sink;

  Future<void> init(String logDir, String logFileName) async {
    final dir = await getApplicationDocumentsDirectory();
    final logPath = '${dir.path}/$logDir';
    await Directory(logPath).create(recursive: true);
    _file = File('$logPath/$logFileName');
    _sink = _file!.openWrite(mode: FileMode.append);
  }

  void write(String content) {
    _sink?.writeln(content);
  }

  Future<void> flush() async {
    await _sink?.flush();
  }

  Future<void> close() async {
    await _sink?.close();
    _sink = null;
    _file = null;
  }

  Future<List<String>> readAllLines() async {
    if (_file == null || !await _file!.exists()) return [];
    return await _file!.readAsLines();
  }

  String? get filePath => _file?.path;
}

Step 4: Create log_service.dart

// apps/lib/core/logging/log_service.dart

import 'package:flutter/foundation.dart';
import 'log_config.dart';
import 'log_entry.dart';
import 'log_file_handler.dart';

class LogService {
  final LogConfig _config;
  LogFileHandler? _fileHandler;
  final _buffer = <String>[];
  static const _maxBufferSize = 50;

  LogService._({required LogConfig config}) : _config = config;

  static Future<LogService> create({LogConfig? config}) async {
    final isRelease = kReleaseMode;
    
    final effectiveConfig = config ?? 
      (isRelease ? LogConfig.forRelease() : LogConfig.forDebug());
    
    final service = LogService._(config: effectiveConfig);
    
    if (effectiveConfig.output == LogOutput.file) {
      service._fileHandler = LogFileHandler();
      await service._fileHandler!.init(
        effectiveConfig.logDir, 
        effectiveConfig.logFileName,
      );
    }
    
    return service;
  }

  String? get logFilePath => _fileHandler?.filePath;

  void _log(LogEntry entry) {
    if (entry.level.index < _config.minLevel.index) return;

    if (_config.output == LogOutput.console) {
      debugPrint(entry.toConsoleString());
      if (entry.stackTrace != null) {
        debugPrint(entry.stackTrace!);
      }
    } else {
      _buffer.add(entry.toFileString());
      if (_buffer.length >= _maxBufferSize) {
        _flushBuffer();
      }
    }
  }

  void _flushBuffer() {
    for (final line in _buffer) {
      _fileHandler?.write(line);
    }
    _buffer.clear();
    _fileHandler?.flush();
  }

  (String?, int?) _extractLocation(StackTrace stackTrace) {
    final frames = stackTrace.toString().split('\n');
    for (final frame in frames) {
      if (frame.contains('.dart')) {
        final match = RegExp(r'#\d+\s+(.+?)\s+\((.+?):(\d+)\)').firstMatch(frame);
        if (match != null) {
          return (match.group(1), int.tryParse(match.group(3) ?? ''));
        }
      }
    }
    return (null, null);
  }

  void debug({
    required String message,
    required String module,
    Map<String, dynamic>? extra,
    StackTrace? stackTrace,
  }) {
    final trace = stackTrace ?? StackTrace.current;
    final (funcName, lineNo) = _extractLocation(trace);
    _log(LogEntry(
      timestamp: DateTime.now(),
      level: LogLevel.debug,
      message: message,
      module: module,
      funcName: funcName,
      lineNo: lineNo,
      extra: extra,
      stackTrace: trace.toString(),
    ));
  }

  void info({
    required String message,
    required String module,
    required Map<String, dynamic> extra,
    StackTrace? stackTrace,
  }) {
    final trace = stackTrace ?? StackTrace.current;
    final (funcName, lineNo) = _extractLocation(trace);
    _log(LogEntry(
      timestamp: DateTime.now(),
      level: LogLevel.info,
      message: message,
      module: module,
      funcName: funcName,
      lineNo: lineNo,
      extra: extra,
      stackTrace: trace.toString(),
    ));
  }

  void warning({
    required String message,
    required String module,
    required Map<String, dynamic> extra,
    StackTrace? stackTrace,
  }) {
    final trace = stackTrace ?? StackTrace.current;
    final (funcName, lineNo) = _extractLocation(trace);
    _log(LogEntry(
      timestamp: DateTime.now(),
      level: LogLevel.warning,
      message: message,
      module: module,
      funcName: funcName,
      lineNo: lineNo,
      extra: extra,
      stackTrace: trace.toString(),
    ));
  }

  void error({
    required String message,
    required Object error,
    required StackTrace stackTrace,
    required String module,
    Map<String, dynamic>? extra,
  }) {
    final (funcName, lineNo) = _extractLocation(stackTrace);
    _log(LogEntry(
      timestamp: DateTime.now(),
      level: LogLevel.error,
      message: message,
      module: module,
      funcName: funcName,
      lineNo: lineNo,
      errorType: error.runtimeType.toString(),
      stackTrace: stackTrace.toString(),
      extra: extra,
    ));
  }

  void flush() {
    _flushBuffer();
    _fileHandler?.flush();
  }

  Future<List<String>> readLogs() async {
    return await _fileHandler?.readAllLines() ?? [];
  }
}

Step 5: Create logger.dart

// apps/lib/core/logging/logger.dart

import 'log_service.dart';

LogService? _globalLogService;

void setLogService(LogService service) {
  _globalLogService = service;
}

LogService _ensureService() {
  return _globalLogService ?? (throw StateError('LogService not initialized'));
}

class Logger {
  final String module;
  final LogService _service;

  Logger(this.module, this._service);

  factory Logger.get(String module) {
    return Logger(module, _ensureService());
  }

  void debug({
    required String message,
    Map<String, dynamic>? extra,
    StackTrace? stackTrace,
  }) => _service.debug(
    message: message,
    module: module,
    extra: extra ?? {},
    stackTrace: stackTrace,
  );

  void info({
    required String message,
    required Map<String, dynamic> extra,
    StackTrace? stackTrace,
  }) => _service.info(
    message: message,
    module: module,
    extra: extra,
    stackTrace: stackTrace,
  );

  void warning({
    required String message,
    required Map<String, dynamic> extra,
    StackTrace? stackTrace,
  }) => _service.warning(
    message: message,
    module: module,
    extra: extra,
    stackTrace: stackTrace,
  );

  void error({
    required String message,
    required Object error,
    required StackTrace stackTrace,
    Map<String, dynamic>? extra,
  }) => _service.error(
    message: message,
    error: error,
    stackTrace: stackTrace,
    module: module,
    extra: extra,
  );
}

Logger getLogger(String module) => Logger.get(module);

Step 6: Commit

git add apps/lib/core/logging/
git commit -m "feat(logging): add logging module skeleton"

Task 2: 全局错误处理器

Files:

  • Create: apps/lib/core/logging/error_handler.dart
  • Modify: apps/lib/main.dart

Step 1: Create error_handler.dart

// apps/lib/core/logging/error_handler.dart

class AppErrorHandler {
  final Logger _logger = getLogger('flutter.error');

  void register() {
    FlutterError.onError = (details) {
      _logger.error(
        message: 'FlutterError: ${details.exceptionAsString()}',
        error: details.exceptionAsString(),
        stackTrace: details.stack ?? StackTrace.current,
        extra: {'context': 'FlutterError.onError'},
      );
      FlutterError.presentError(details);
    };
  }
}

Step 2: Modify main.dart

// main.dart

import 'core/logging/logger.dart';
import 'core/logging/log_service.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  final logService = await LogService.create();
  Logger.setLogService(logService);
  
  AppErrorHandler().register();
  
  await configureDependencies();
  await Env.init();

  getLogger('app').info(
    message: 'App starting...',
    extra: {'version': Env.appVersion, 'platform': Env.platformName},
  );
  
  runApp(const LinksyApp());
}

Step 3: Commit

git add apps/lib/core/logging/error_handler.dart apps/lib/main.dart
git commit -m "feat(logging): add global error handler"

Task 3: 添加 path_provider 依赖

Files:

  • Modify: apps/pubspec.yaml

Step 1: Add dependency

dependencies:
  path_provider: ^2.1.2

Step 2: Commit

git add apps/pubspec.yaml
git commit -m "feat(logging): add path_provider dependency"

Task 4: 迁移 ChatBloc 示例

Files:

  • Modify: apps/lib/features/chat/presentation/bloc/chat_bloc.dart

Step 1: Add logging to ChatBloc

// chat_bloc.dart

import '../../../../core/logging/logger.dart';

class ChatBloc extends Bloc<ChatBlocEvent, ChatBlocState> {
  final _logger = getLogger('features.chat.bloc');

  void _onChatSend(ChatBlocSend event) {
    _logger.info(
      message: 'Sending chat message',
      extra: {
        'message_length': event.message.length,
        'attachments_count': event.attachments.length,
      },
    );

    try {
      // ... 业务逻辑
      _logger.info(
        message: 'Chat message sent successfully',
        extra: {'message_id': result.id},
      );
    } catch (e, stackTrace) {
      _logger.error(
        message: 'Failed to send chat message',
        error: e,
        stackTrace: stackTrace,
        extra: {'message': event.message},
      );
    }
  }
}

Step 2: Commit

git add apps/lib/features/chat/presentation/bloc/chat_bloc.dart
git commit -m "feat(logging): add logging to ChatBloc"

日志输出示例

Debug 模式 (console)

2026-04-01T10:30:00.123 DEBUG   [features.chat.bloc@onChatSend:142] Chat message sent successfully {'message_id': 'abc123'}
#0      ChatBloc._onChatSend (package:social_app/features/chat/presentation/bloc/chat_bloc.dart:142)
...

2026-04-01T10:30:01.456 ERROR   [features.chat.bloc@onChatSend:156] Failed to send chat message [SocketException]
  #0      ChatBloc._onChatSend (package:social_app/features/chat/presentation/bloc/chat_bloc.dart:156)
  ...

Release 模式 (file)

[2026-04-01T10:30:00.123] DEBUG [features.chat.bloc]
  at onChatSend:142
  Chat message sent successfully
  Extra: {'message_id': 'abc123'}

[2026-04-01T10:30:01.456] ERROR [features.chat.bloc]
  at onChatSend:156
  Failed to send chat message
  Error: SocketException
  StackTrace:
  #0      ChatBloc._onChatSend (package:social_app/features/chat/presentation/bloc/chat_bloc.dart:156)
  ...
  Extra: {'message': 'Hello'}

API 使用规范

import 'core/logging/logger.dart';

final logger = getLogger('module.path');

// debug - 可选 extra
logger.debug(message: 'Debug info', extra: {'key': 'value'});

// info - 必须有 extra
logger.info(message: 'Operation completed', extra: {'id': '123'});

// warning - 必须有 extra
logger.warning(message: 'Deprecated API called', extra: {'api': 'old'});

// error - 必须有 error 和 stackTrace
logger.error(
  message: 'Operation failed',
  error: e,
  stackTrace: stackTrace,
);

Task 5: 单元测试

Files:

  • Create: apps/test/core/logging/log_entry_test.dart

Step 1: Write tests

void main() {
  group('LogEntry', () {
    test('toJson should serialize correctly', () {
      final entry = LogEntry(
        timestamp: DateTime(2026, 4, 1, 10, 0, 0),
        level: LogLevel.error,
        message: 'Test error',
        module: 'test.module',
        funcName: 'testFunc',
        lineNo: 42,
        errorType: 'TestError',
        stackTrace: 'test stack',
        extra: {'key': 'value'},
      );
      
      final json = entry.toJson();
      
      expect(json['level'], 'error');
      expect(json['error_type'], 'TestError');
      expect(json['extra'], {'key': 'value'});
    });
  });
}

Step 2: Commit

git add apps/test/core/logging/
git commit -m "test(logging): add logging tests"

执行方式

两个选择:

  1. Subagent-Driven (当前 session) - 我 dispatch 新的 subagent 逐个执行任务,任务间 review,快速迭代

  2. Parallel Session (新 session) - 在 worktree 中打开新 session,使用 executing-plans,批量执行带 checkpoint

选择哪个?