feat(debug): 增加 HTTP 请求响应日志

This commit is contained in:
Zhou Weidong committed 2026-10-22 12:15:51 +08:00
1 parent dc4b60270d
commit 7a588898fa
6 files changed
+534

No files matched your search

+4
View File
@@ -1,8 +1,11 @@
// Author: fruit.com.cn
import 'package:dio/dio.dart';
import '../../config/app_config.dart';
import 'api_exception.dart';
import 'api_interceptors.dart';
import 'network_log_interceptor.dart';
/// `ApiException` 从这里也能拿到:历史调用方一律 `import 'api_client.dart'`,不必改 import。
export 'api_exception.dart';
@@ -39,6 +42,7 @@ class ApiClient {
deviceIdReader: deviceIdReader,
),
const EnvelopeInterceptor(),
NetworkLogInterceptor(),
]);
if (adapter != null) {
_dio.httpClientAdapter = adapter;
@@ -5,6 +5,7 @@ import 'dart:convert';
import 'package:dio/dio.dart';
import 'api_exception.dart';
import 'network_log_interceptor.dart';
/// 请求级开关:放在 `RequestOptions.extra` 里,由 `ApiClient` 写入、拦截器读取。
class ApiRequestFlags {
@@ -72,6 +73,10 @@ class EnvelopeInterceptor extends Interceptor {
ResponseInterceptorHandler handler,
) {
final options = response.requestOptions;
if (NetworkLogInterceptor.isEnabled) {
options.extra[NetworkLogInterceptor.rawResponseDataExtraKey] =
response.data;
}
final shell = _decodeShell(response.data);
final code = shell['code'] as int? ?? -1;
final message = shell['message'] as String?;
@@ -0,0 +1,219 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'dart:math' as math;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
/// 调试构建下输出 Dio 请求、响应和异常详情。
class NetworkLogInterceptor extends Interceptor {
/// 在调试时关闭日志的本地常量开关。
static const bool enabled = true;
/// Envelope 解包前暂存原始响应体,供链尾日志读取。
static const String rawResponseDataExtraKey = 'app.http.rawResponseData';
/// 请求、响应体单条日志最大字符数。
static const int maxBodyLength = 3000;
static const String _truncatedSuffix = '…(truncated)';
static const String _stopwatchKey = 'app.http.stopwatch';
static const String _startedAtKey = 'app.http.startedAt';
static const String _emptyBody = '<empty>';
static const String _redacted = '***';
/// 允许单测注入日志收集器;默认走 Android Studio Run 控制台。
NetworkLogInterceptor({void Function(String)? logger})
: _logger = logger ?? debugPrint;
final void Function(String) _logger;
/// release/profile 下恒为 false,保证零 HTTP 日志输出。
static bool get isEnabled => kDebugMode && enabled;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (!isEnabled) {
return handler.next(options);
}
final stopwatch = Stopwatch()..start();
options.extra[_stopwatchKey] = stopwatch;
options.extra[_startedAtKey] = DateTime.now().toIso8601String();
final body = _formatRequestBody(options.data);
final message = StringBuffer()
..writeln('[HTTP] → ${options.method} ${options.uri}')
..writeln('[HTTP] → 耗时起点:${options.extra[_startedAtKey]}')
..writeln('[HTTP] → headers:${_formatHeaders(options.headers)}')
..writeln('[HTTP] → body:$body');
_logger(message.toString().trimRight());
handler.next(options);
}
@override
void onResponse(
Response<dynamic> response,
ResponseInterceptorHandler handler,
) {
if (!isEnabled) {
return handler.next(response);
}
final options = response.requestOptions;
final elapsed = _elapsedMilliseconds(options);
final rawBody = options.extra[rawResponseDataExtraKey] ?? response.data;
final body = _formatResponseBody(
rawBody,
response.headers.value(Headers.contentTypeHeader),
);
final message = StringBuffer()
..writeln(
'[HTTP] ← ${response.statusCode ?? 0} ${elapsed}ms '
'${options.method} ${options.uri}',
)
..writeln('[HTTP] ← body:$body');
_logger(message.toString().trimRight());
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (!isEnabled) {
return handler.next(err);
}
final options = err.requestOptions;
final message = StringBuffer()
..writeln(
'[HTTP] ✗ ${err.type.name} ${_elapsedMilliseconds(options)}ms '
'${options.uri} ${err.message ?? ''}',
)
..write(_errorBodyLine(err));
_logger(message.toString().trimRight());
handler.next(err);
}
int _elapsedMilliseconds(RequestOptions options) {
final stopwatch = options.extra[_stopwatchKey];
if (stopwatch is Stopwatch) {
return stopwatch.elapsedMilliseconds;
}
return 0;
}
String _errorBodyLine(DioException error) {
final rawBody =
error.requestOptions.extra[rawResponseDataExtraKey] ??
error.response?.data;
if (rawBody == null) {
return '';
}
final body = _formatResponseBody(
rawBody,
error.response?.headers.value(Headers.contentTypeHeader),
);
return '\n[HTTP] ✗ body:$body';
}
String _formatHeaders(Map<String, dynamic> headers) {
if (headers.isEmpty) {
return '{}';
}
final entries = headers.entries.toList()
..sort((left, right) => left.key.compareTo(right.key));
final safe = <String, String>{
for (final entry in entries)
entry.key: _safeHeaderValue(entry.key, entry.value),
};
return _truncate(jsonEncode(safe));
}
String _safeHeaderValue(String name, Object? value) {
final text = value?.toString() ?? '';
if (name.toLowerCase() == 'authorization') {
return _maskAuthorization(text);
}
if (name.toLowerCase() == 'x-device-id') {
return text;
}
return _redacted;
}
String _maskAuthorization(String value) {
if (value.length <= 8) {
return _redacted;
}
return '${value.substring(0, math.min(8, value.length))}...';
}
String _formatRequestBody(Object? body) {
if (body == null) {
return _emptyBody;
}
final json = _tryPrettyJson(body);
if (json != null) {
return _truncate(json);
}
final length = switch (body) {
FormData data => data.length,
List<int> bytes => bytes.length,
_ => utf8.encode(body.toString()).length,
};
return '${body.runtimeType} ($length bytes)';
}
String _formatResponseBody(Object? body, String? contentType) {
if (body == null) {
return _emptyBody;
}
if (body is List<int>) {
final bytes = body is Uint8List ? body : Uint8List.fromList(body);
if (_isBinary(contentType)) {
return '<binary ${contentType ?? 'unknown'}, ${bytes.length} bytes>';
}
final text = utf8.decode(bytes, allowMalformed: true);
return _formatText(text);
}
return _formatText(body.toString());
}
String _formatText(String text) {
if (text.isEmpty) {
return _emptyBody;
}
return _truncate(_tryPrettyJson(text) ?? text);
}
String? _tryPrettyJson(Object? body) {
try {
final value = body is String
? jsonDecode(body)
: body is List<int>
? jsonDecode(utf8.decode(body, allowMalformed: true))
: body;
if (value is! Map && value is! List) {
return null;
}
return const JsonEncoder.withIndent(' ').convert(value);
} catch (_) {
return null;
}
}
bool _isBinary(String? contentType) {
final type = contentType?.toLowerCase() ?? '';
return type.startsWith('image/') ||
type.startsWith('audio/') ||
type.startsWith('video/') ||
type.startsWith('font/') ||
type.contains('application/octet-stream') ||
type.contains('application/pdf') ||
type.contains('application/zip');
}
String _truncate(String value) {
if (value.length <= maxBodyLength) {
return value;
}
final end = maxBodyLength - _truncatedSuffix.length;
return '${value.substring(0, end)}$_truncatedSuffix';
}
}