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

681 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```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**
```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**
```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**
```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**
```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**
```bash
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**
```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**
```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**
```bash
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**
```yaml
dependencies:
path_provider: ^2.1.2
```
**Step 2: Commit**
```bash
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**
```dart
// 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**
```bash
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 使用规范
```dart
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**
```dart
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**
```bash
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
**选择哪个?**