app scaffold
This commit is contained in:
@@ -0,0 +1 @@
|
||||
include: ../../analysis_options.yaml
|
||||
@@ -0,0 +1,19 @@
|
||||
/// 日志与崩溃上报的统一入口。来源:conti-docs/13-observability-analytics.md。
|
||||
///
|
||||
/// 边界约定:
|
||||
/// - 业务代码只用 [AppLogger] / [CrashReporter] 两个接口,**不直接 import
|
||||
/// `logger` 或 `sentry_flutter`**,也不用 `print` / `debugPrint`;
|
||||
/// - 全仓库只有本包的 `sentry_*.dart` 和 `app/bootstrap.dart` 可以见到
|
||||
/// Sentry SDK。
|
||||
library;
|
||||
|
||||
export 'src/app_logger.dart';
|
||||
export 'src/crash_breadcrumb_observer.dart';
|
||||
export 'src/crash_reporter.dart';
|
||||
export 'src/log_buffer.dart';
|
||||
export 'src/logger_app_logger.dart';
|
||||
export 'src/providers.dart';
|
||||
export 'src/scrubber.dart';
|
||||
export 'src/sentry_crash_reporter.dart';
|
||||
export 'src/sentry_scrubber.dart';
|
||||
export 'src/test_exception.dart';
|
||||
@@ -0,0 +1,42 @@
|
||||
/// 日志门面。来源:13 §二。
|
||||
library;
|
||||
|
||||
/// 全仓库唯一允许调用的日志入口。
|
||||
///
|
||||
/// **禁止直接用 `logger` 包、`print`、`debugPrint`**:
|
||||
/// - 换实现只改一处;
|
||||
/// - `print` 在 release 下不会被剥离,是一条实打实的信息泄漏通道。
|
||||
abstract interface class AppLogger {
|
||||
/// 调试信息。只在 dev 输出。
|
||||
void d(String message, {Map<String, Object?>? data});
|
||||
|
||||
/// 关键流程节点。uat 及以上输出。
|
||||
void i(String message, {Map<String, Object?>? data});
|
||||
|
||||
/// 可恢复的异常状况。
|
||||
void w(String message, {Object? error, StackTrace? stackTrace});
|
||||
|
||||
/// 错误。prod 下也会进环形缓冲并随崩溃上报。
|
||||
void e(String message, {Object? error, StackTrace? stackTrace});
|
||||
}
|
||||
|
||||
/// 测试和早期启动阶段用的空实现。
|
||||
///
|
||||
/// 启动最早期(`AppEnv` 还没装好)就可能有日志调用,那时不能因为
|
||||
/// logger 未初始化而抛异常。
|
||||
class NoopAppLogger implements AppLogger {
|
||||
/// 创建一个什么都不做的 logger。
|
||||
const NoopAppLogger();
|
||||
|
||||
@override
|
||||
void d(String message, {Map<String, Object?>? data}) {}
|
||||
|
||||
@override
|
||||
void i(String message, {Map<String, Object?>? data}) {}
|
||||
|
||||
@override
|
||||
void w(String message, {Object? error, StackTrace? stackTrace}) {}
|
||||
|
||||
@override
|
||||
void e(String message, {Object? error, StackTrace? stackTrace}) {}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/// 路由面包屑观察者。来源:13 §一「崩溃前的页面路径」。
|
||||
library;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'crash_reporter.dart';
|
||||
|
||||
/// 把路由变化写进 Sentry 面包屑。
|
||||
///
|
||||
/// **记的是路由名不是完整 URL**——`/webview?target=X&ticket=...` 里带着票据
|
||||
/// (见 10 与本包的 `scrubber.dart`)。
|
||||
///
|
||||
/// 挂载位置:`app/` 在构造 GoRouter 时通过 `navigatorObserversProvider`
|
||||
/// 注入。`core_router` 不依赖 `core_logging`(那不在 01 允许的三条 core 间
|
||||
/// 依赖里),所以这个类住在这里而不是路由包。详见 SCAFFOLD-NOTES.md §G。
|
||||
class CrashBreadcrumbObserver extends NavigatorObserver {
|
||||
/// [reporter] 通常是 `SentryCrashReporter`。
|
||||
CrashBreadcrumbObserver(this._reporter);
|
||||
|
||||
final CrashReporter _reporter;
|
||||
|
||||
@override
|
||||
void didPush(Route<Object?> route, Route<Object?>? previousRoute) => _leave('push', route);
|
||||
|
||||
@override
|
||||
void didPop(Route<Object?> route, Route<Object?>? previousRoute) => _leave('pop', route);
|
||||
|
||||
@override
|
||||
void didReplace({Route<Object?>? newRoute, Route<Object?>? oldRoute}) {
|
||||
if (newRoute != null) _leave('replace', newRoute);
|
||||
}
|
||||
|
||||
void _leave(String action, Route<Object?> route) {
|
||||
// name 为空时用 '<unnamed>',绝不回退到 route.settings.arguments 或
|
||||
// 完整 location——那两个都可能带业务参数。
|
||||
_reporter.leaveBreadcrumb('nav: $action ${route.settings.name ?? '<unnamed>'}');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/// 崩溃上报门面。来源:13 §一。
|
||||
library;
|
||||
|
||||
/// 业务代码唯一可见的崩溃上报接口。
|
||||
///
|
||||
/// 这一层**不是**为了"将来可能换 Sentry"——是为了测试里能直接 mock 掉、
|
||||
/// 不必真的初始化 SDK,以及让 `feature_*` 不多一条对三方 SDK 的直接依赖
|
||||
/// (见 01 的依赖规则)。
|
||||
abstract interface class CrashReporter {
|
||||
/// 只传后端的 `userId`,**不传手机号 / 姓名**(13 §一「用户与门店上下文」)。
|
||||
void setUser(String userId);
|
||||
|
||||
/// 设置一个自定义维度。`storeId` 一定要带——门店设备型号和网络环境高度
|
||||
/// 集中,很多崩溃是设备相关的,没有这个维度只能盲猜。
|
||||
void setTag(String key, String value);
|
||||
|
||||
/// 清空用户与门店维度。登出时调用,否则共用设备上会串号。
|
||||
void clearUser();
|
||||
|
||||
/// 面包屑。记路由名而不是完整 URL——URL 的 query 里带着票据。
|
||||
void leaveBreadcrumb(String message);
|
||||
|
||||
/// 上报一个异常。
|
||||
///
|
||||
/// [extra] 用于带 `traceId` 这类能直接关联到后端 ELK 的字段。
|
||||
void report(
|
||||
Object error,
|
||||
StackTrace? stack, {
|
||||
Map<String, String> extra = const <String, String>{},
|
||||
});
|
||||
}
|
||||
|
||||
/// 测试与未接入环境用的空实现。
|
||||
class NoopCrashReporter implements CrashReporter {
|
||||
/// 创建一个什么都不做的上报器。
|
||||
const NoopCrashReporter();
|
||||
|
||||
@override
|
||||
void setUser(String userId) {}
|
||||
|
||||
@override
|
||||
void setTag(String key, String value) {}
|
||||
|
||||
@override
|
||||
void clearUser() {}
|
||||
|
||||
@override
|
||||
void leaveBreadcrumb(String message) {}
|
||||
|
||||
@override
|
||||
void report(
|
||||
Object error,
|
||||
StackTrace? stack, {
|
||||
Map<String, String> extra = const <String, String>{},
|
||||
}) {}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/// 内存环形缓冲——崩溃时的"黑匣子"。来源:13 §二「级别与环境」。
|
||||
library;
|
||||
|
||||
/// 一条已经脱敏、可以直接外发的日志。
|
||||
class LogRecord {
|
||||
/// 构造一条日志记录。[message] 必须是脱敏之后的内容。
|
||||
const LogRecord({required this.level, required this.message, required this.timestamp});
|
||||
|
||||
/// 级别缩写:`D` / `I` / `W` / `E`。
|
||||
final String level;
|
||||
|
||||
/// 已脱敏的消息体。
|
||||
final String message;
|
||||
|
||||
/// 记录时刻(本地时区)。
|
||||
final DateTime timestamp;
|
||||
|
||||
@override
|
||||
String toString() => '${timestamp.toIso8601String()} [$level] $message';
|
||||
}
|
||||
|
||||
/// 固定容量的环形缓冲。
|
||||
///
|
||||
/// 只在内存里,**不落磁盘**——日志文件会成为新的泄漏面,且门店设备上
|
||||
/// `logcat` 与外部存储都不是可信边界。App 退出即消失。
|
||||
class LogBuffer {
|
||||
/// [capacity] 默认 500,与 13 §二的表格一致。
|
||||
LogBuffer({this.capacity = 500}) : assert(capacity > 0, 'capacity 必须为正');
|
||||
|
||||
/// 最多保留的条数。
|
||||
final int capacity;
|
||||
|
||||
final List<LogRecord> _records = <LogRecord>[];
|
||||
|
||||
/// 追加一条,超出容量时丢弃最旧的。
|
||||
void add(LogRecord record) {
|
||||
_records.add(record);
|
||||
if (_records.length > capacity) {
|
||||
_records.removeRange(0, _records.length - capacity);
|
||||
}
|
||||
}
|
||||
|
||||
/// 取最近 [count] 条,按时间正序。
|
||||
///
|
||||
/// 崩溃上报时实际带走的是 30 条而不是全部 500 条——单个 Sentry 事件的
|
||||
/// 体积有上限,超了整条事件会被丢弃,那比少带日志更糟。
|
||||
List<LogRecord> recent([int count = 30]) {
|
||||
if (_records.length <= count) return List<LogRecord>.unmodifiable(_records);
|
||||
return List<LogRecord>.unmodifiable(_records.sublist(_records.length - count));
|
||||
}
|
||||
|
||||
/// 当前条数。
|
||||
int get length => _records.length;
|
||||
|
||||
/// 清空。登出时调用,避免上一个账号的日志被下一个账号的崩溃带走。
|
||||
void clear() => _records.clear();
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/// `AppLogger` 的 logger 包实现。来源:13 §二。
|
||||
library;
|
||||
|
||||
import 'package:core_foundation/core_foundation.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
import 'app_logger.dart';
|
||||
import 'log_buffer.dart';
|
||||
import 'scrubber.dart';
|
||||
|
||||
/// 日志级别,按严重程度递增。
|
||||
enum LogSeverity {
|
||||
/// 调试。
|
||||
debug('D'),
|
||||
|
||||
/// 常规信息。
|
||||
info('I'),
|
||||
|
||||
/// 警告。
|
||||
warning('W'),
|
||||
|
||||
/// 错误。
|
||||
error('E');
|
||||
|
||||
const LogSeverity(this.tag);
|
||||
|
||||
/// 出现在环形缓冲文本里的单字母标记。
|
||||
final String tag;
|
||||
}
|
||||
|
||||
/// 生产可用的 [AppLogger]。
|
||||
///
|
||||
/// 三条硬约束(13 §二):
|
||||
/// 1. **prod 不输出到控制台**——Android 的 `logcat` 是全局可读的,门店设备上
|
||||
/// 这不是理论风险;
|
||||
/// 2. 所有内容进环形缓冲前**必须过一遍脱敏器**,因为缓冲会随崩溃外发;
|
||||
/// 3. 级别按 flavor 分档:dev=debug / uat=info / prod=warning。
|
||||
class LoggerAppLogger implements AppLogger {
|
||||
/// 按 [env] 推导级别和控制台开关。
|
||||
///
|
||||
/// [buffer] 由调用方持有并同时交给 `CrashReporter`,两边必须是同一个实例,
|
||||
/// 否则崩溃上报里带的是一份空日志。
|
||||
LoggerAppLogger({required AppEnv env, required this.buffer, Logger? logger})
|
||||
: _minSeverity = _severityFor(env.flavor),
|
||||
// enableLog 是 dart-define 里的开关,prod 即使误开也不打控制台。
|
||||
_console = env.enableLog && !env.isProd,
|
||||
_logger =
|
||||
logger ??
|
||||
Logger(
|
||||
printer: PrettyPrinter(
|
||||
methodCount: 0,
|
||||
errorMethodCount: 8,
|
||||
colors: true,
|
||||
printEmojis: false,
|
||||
),
|
||||
// 级别过滤由本类统一做,交给 logger 会多一层不一致的语义。
|
||||
filter: ProductionFilter(),
|
||||
level: Level.all,
|
||||
);
|
||||
|
||||
static LogSeverity _severityFor(AppFlavor flavor) => switch (flavor) {
|
||||
AppFlavor.dev => LogSeverity.debug,
|
||||
AppFlavor.uat => LogSeverity.info,
|
||||
AppFlavor.prod => LogSeverity.warning,
|
||||
};
|
||||
|
||||
/// 崩溃时随事件外发的"黑匣子"。与 `beforeSend` 读的必须是同一个实例。
|
||||
final LogBuffer buffer;
|
||||
|
||||
final LogSeverity _minSeverity;
|
||||
final bool _console;
|
||||
final Logger _logger;
|
||||
|
||||
@override
|
||||
void d(String message, {Map<String, Object?>? data}) =>
|
||||
_log(LogSeverity.debug, message, data: data);
|
||||
|
||||
@override
|
||||
void i(String message, {Map<String, Object?>? data}) =>
|
||||
_log(LogSeverity.info, message, data: data);
|
||||
|
||||
@override
|
||||
void w(String message, {Object? error, StackTrace? stackTrace}) =>
|
||||
_log(LogSeverity.warning, message, error: error, stackTrace: stackTrace);
|
||||
|
||||
@override
|
||||
void e(String message, {Object? error, StackTrace? stackTrace}) =>
|
||||
_log(LogSeverity.error, message, error: error, stackTrace: stackTrace);
|
||||
|
||||
void _log(
|
||||
LogSeverity severity,
|
||||
String message, {
|
||||
Map<String, Object?>? data,
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
}) {
|
||||
if (severity.index < _minSeverity.index) return;
|
||||
|
||||
final String line = data == null || data.isEmpty ? message : '$message ${scrubMap(data)}';
|
||||
|
||||
buffer.add(
|
||||
LogRecord(
|
||||
level: severity.tag,
|
||||
// error 的 toString 无法结构化脱敏,所以约定反过来:仓库内的异常类型
|
||||
// (见 core_foundation 的 AppException)只带 code / traceId,不带任何
|
||||
// 凭据或用户输入原文。三方异常同理,发现例外要在这里显式拦。
|
||||
message: error == null ? line : '$line | error=$error',
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
if (!_console) return;
|
||||
switch (severity) {
|
||||
case LogSeverity.debug:
|
||||
_logger.d(line);
|
||||
case LogSeverity.info:
|
||||
_logger.i(line);
|
||||
case LogSeverity.warning:
|
||||
_logger.w(line, error: error, stackTrace: stackTrace);
|
||||
case LogSeverity.error:
|
||||
_logger.e(line, error: error, stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/// core_logging 的 Riverpod 接线。
|
||||
library;
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'app_logger.dart';
|
||||
import 'crash_reporter.dart';
|
||||
import 'log_buffer.dart';
|
||||
|
||||
/// 环形缓冲的唯一实例。
|
||||
///
|
||||
/// `AppLogger` 往里写、`beforeSend` 从里读,两边必须是同一个对象,
|
||||
/// 否则崩溃上报里带的是一份空日志。
|
||||
final Provider<LogBuffer> logBufferProvider = Provider<LogBuffer>((Ref ref) => LogBuffer());
|
||||
|
||||
/// 全局日志入口。
|
||||
///
|
||||
/// 默认是 [NoopAppLogger]——真实实现需要 `AppEnv`,由 `app/` 的 `bootstrap()`
|
||||
/// 在拿到环境配置后 override。这样保证:即使某个测试忘了 override,
|
||||
/// 也只是没有日志,而不是启动就抛异常。
|
||||
final Provider<AppLogger> appLoggerProvider = Provider<AppLogger>(
|
||||
(Ref ref) => const NoopAppLogger(),
|
||||
);
|
||||
|
||||
/// 全局崩溃上报入口。默认空实现,同 [appLoggerProvider]。
|
||||
final Provider<CrashReporter> crashReporterProvider = Provider<CrashReporter>(
|
||||
(Ref ref) => const NoopCrashReporter(),
|
||||
);
|
||||
@@ -0,0 +1,85 @@
|
||||
/// 脱敏器。来源:13 §二「脱敏」、PRD §21.3。
|
||||
///
|
||||
/// 这个文件是全仓库唯一的脱敏实现,`AppLogger`、Sentry 的两个钩子、以及
|
||||
/// 网络层的日志拦截器都必须走这里,不允许各写各的。
|
||||
library;
|
||||
|
||||
/// 命中即整体替换为 [redacted] 的 key(大小写不敏感)。
|
||||
///
|
||||
/// `code` 在这里指短信验证码。业务错误码字段名统一叫 `bizCode` / `errorCode`,
|
||||
/// 不会被误伤——见 12 的 `ApiResult` 契约。
|
||||
const Set<String> sensitiveKeys = <String>{
|
||||
'token',
|
||||
'accessToken',
|
||||
'refreshToken',
|
||||
'authorization',
|
||||
'ticket',
|
||||
'password',
|
||||
'code',
|
||||
'phone',
|
||||
'mobile',
|
||||
'idCard',
|
||||
'bankCard',
|
||||
};
|
||||
|
||||
/// 需要走掩码而不是整体抹掉的 key——保留首尾便于人工比对。
|
||||
const Set<String> _maskedKeys = <String>{'phone', 'mobile'};
|
||||
|
||||
/// 统一的替换文案。出现在日志里时应当一眼看出是被脱敏了,而不是"值为空"。
|
||||
const String redacted = '<redacted>';
|
||||
|
||||
/// 手机号掩码:`13812345678` → `138****5678`。
|
||||
///
|
||||
/// 长度不足 11 位时不做部分保留——短号段保留首三位仍可能足以定位到人。
|
||||
String maskPhone(String v) => v.length >= 11 ? '${v.substring(0, 3)}****${v.substring(7)}' : '***';
|
||||
|
||||
/// 递归脱敏一个结构化日志载荷。
|
||||
///
|
||||
/// 只对 Map 的 **key** 做判断——裸字符串没有可靠的判据,不做猜测式匹配。
|
||||
/// 因此约定:凭据只能以键值对的形式进日志,不允许拼进自由文本。
|
||||
/// Map / List 之外的类型原样返回;调用方拿到的是新对象,原始 Map 不被修改。
|
||||
Object? scrubValue(Object? value) {
|
||||
if (value is Map) {
|
||||
return <String, Object?>{
|
||||
for (final MapEntry<Object?, Object?> e in value.entries)
|
||||
'${e.key}': _scrubEntry('${e.key}', e.value),
|
||||
};
|
||||
}
|
||||
if (value is List) {
|
||||
return value.map<Object?>(scrubValue).toList();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/// [scrubValue] 的 Map 入口,返回类型收窄,供调用方直接塞进日志。
|
||||
Map<String, Object?> scrubMap(Map<String, Object?> data) =>
|
||||
scrubValue(data)! as Map<String, Object?>;
|
||||
|
||||
Object? _scrubEntry(String key, Object? value) {
|
||||
final String lower = key.toLowerCase();
|
||||
if (_maskedKeys.any((String k) => lower == k.toLowerCase())) {
|
||||
return value is String ? maskPhone(value) : redacted;
|
||||
}
|
||||
if (sensitiveKeys.any((String k) => lower.contains(k.toLowerCase()))) {
|
||||
return redacted;
|
||||
}
|
||||
return scrubValue(value);
|
||||
}
|
||||
|
||||
/// 去掉 URL 的 query 和 fragment。
|
||||
///
|
||||
/// H5 的 URL 上带着 `ticket`(见 10),整条打出去等于打 token。
|
||||
/// 解析失败时**整条抹掉**而不是原样返回——解析不出来的 URL 更可疑,不是更安全。
|
||||
///
|
||||
/// 实现注意:13 §二给的 `uri.replace(query: '')` 会留下一个空的 `?`(连同
|
||||
/// `fragment: ''` 会得到 `...?#`),所以这里直接重建 Uri。
|
||||
String scrubUrl(String url) {
|
||||
final Uri? uri = Uri.tryParse(url);
|
||||
if (uri == null) return redacted;
|
||||
return Uri(
|
||||
scheme: uri.hasScheme ? uri.scheme : null,
|
||||
host: uri.host.isEmpty ? null : uri.host,
|
||||
port: uri.hasPort ? uri.port : null,
|
||||
path: uri.path,
|
||||
).toString();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/// `CrashReporter` 的 Sentry 实现。来源:13 §一。
|
||||
///
|
||||
/// **本文件是全仓库唯一允许 `import 'package:sentry_flutter/...'` 的地方**
|
||||
/// (另加 app/ 里的 `SentryFlutter.init`)。
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import 'crash_reporter.dart';
|
||||
|
||||
/// 把 [CrashReporter] 转接到 Sentry SDK。
|
||||
///
|
||||
/// 所有方法都是"发射后不管":上报本身绝不能阻塞或影响业务流程,
|
||||
/// 因此统一 `unawaited` 并吞掉自身异常。
|
||||
class SentryCrashReporter implements CrashReporter {
|
||||
/// 创建一个转接到全局 `Sentry` 的上报器。
|
||||
const SentryCrashReporter();
|
||||
|
||||
@override
|
||||
void setUser(String userId) => _configure((Scope scope) async {
|
||||
// 只传 ID。手机号、姓名、IP 一律不带(sendDefaultPii 也已关闭)。
|
||||
await scope.setUser(SentryUser(id: userId));
|
||||
});
|
||||
|
||||
@override
|
||||
void setTag(String key, String value) => _configure((Scope scope) => scope.setTag(key, value));
|
||||
|
||||
@override
|
||||
void clearUser() => _configure((Scope scope) async {
|
||||
await scope.setUser(null);
|
||||
await scope.removeTag('storeId');
|
||||
await scope.removeTag('roleCode');
|
||||
});
|
||||
|
||||
@override
|
||||
void leaveBreadcrumb(String message) {
|
||||
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: message)).catchError((_) {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void report(
|
||||
Object error,
|
||||
StackTrace? stack, {
|
||||
Map<String, String> extra = const <String, String>{},
|
||||
}) {
|
||||
unawaited(
|
||||
Sentry.captureException(
|
||||
error,
|
||||
stackTrace: stack,
|
||||
withScope: (Scope scope) async {
|
||||
if (extra.isNotEmpty) {
|
||||
// traceId 放这里——一条崩溃能直接关联到后端 ELK 里的那次请求。
|
||||
await scope.setContexts('app_extra', extra);
|
||||
}
|
||||
},
|
||||
).then<void>((_) {}).catchError((_) {}),
|
||||
);
|
||||
}
|
||||
|
||||
void _configure(FutureOr<void> Function(Scope) callback) {
|
||||
unawaited(() async {
|
||||
try {
|
||||
await Sentry.configureScope(callback);
|
||||
} on Object {
|
||||
// 上报链路自身的异常不能外溢到业务流程。
|
||||
}
|
||||
}());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/// Sentry 的两个脱敏钩子。来源:13 §二「脱敏」。
|
||||
///
|
||||
/// 这两个钩子是 `SentryFlutter.init` 里 `beforeBreadcrumb` / `beforeSend`
|
||||
/// 的实现,**一个都不能漏**:Sentry 默认会自动记录所有 HTTP 请求作为面包屑,
|
||||
/// 我们在 05/10 里辛苦保证的"URL 不落日志"会被这条默认行为绕过去——
|
||||
/// 它不走我们的 `AppLogger`。
|
||||
library;
|
||||
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import 'log_buffer.dart';
|
||||
import 'scrubber.dart';
|
||||
|
||||
/// 面包屑脱敏:剥掉 URL 的 query(里面可能带 `ticket` / `token`)。
|
||||
Breadcrumb? scrubBreadcrumb(Breadcrumb? crumb, Hint hint) {
|
||||
if (crumb == null) return null;
|
||||
final Object? url = crumb.data?['url'];
|
||||
if (url is String) {
|
||||
crumb.data?['url'] = scrubUrl(url);
|
||||
}
|
||||
return crumb;
|
||||
}
|
||||
|
||||
/// 事件脱敏 + 挂载"黑匣子"。
|
||||
///
|
||||
/// 返回的 `beforeSend` 闭包做两件事:
|
||||
/// 1. 把环形缓冲里**最近 30 条**日志作为 context 附上——不是全部 500 条,
|
||||
/// 单个事件有体积上限,超了整条事件会被丢弃;
|
||||
/// 2. 剥掉请求 URL 的 query。
|
||||
BeforeSendCallback buildScrubEvent(LogBuffer buffer) {
|
||||
return (SentryEvent event, Hint hint) {
|
||||
final SentryRequest? request = event.request;
|
||||
final String? url = request?.url;
|
||||
if (request != null && url != null) {
|
||||
request.url = scrubUrl(url);
|
||||
}
|
||||
|
||||
return event
|
||||
..contexts['app_logs'] = <String, Object?>{
|
||||
'recent': buffer.recent().map((LogRecord r) => r.toString()).toList(growable: false),
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/// 上报链路的自检入口。来源:13 §一「验证接入真的成功了」。
|
||||
library;
|
||||
|
||||
import 'package:core_foundation/core_foundation.dart';
|
||||
|
||||
/// 崩溃上报最常见的失败模式是**静默不上报**——数据没传上来,但你以为
|
||||
/// App 很稳定。所以每次发版前在 dev 上调一次这个函数,确认 Android 和 iOS
|
||||
/// 都能在 Sentry 里看到,**并且堆栈是可读的 Dart 文件名行号而不是 `_x12`**。
|
||||
///
|
||||
/// 只在 dev flavor 下可调;uat/prod 调用会直接抛 [StateError],
|
||||
/// 这样误留在代码里的调用会在测试环境就暴露,而不是污染线上崩溃率。
|
||||
Never throwTestException(AppEnv env) {
|
||||
if (env.flavor != AppFlavor.dev) {
|
||||
throw StateError('throwTestException 只允许在 dev flavor 调用');
|
||||
}
|
||||
throw Exception('Sentry 接入自检:这是一条人为触发的测试异常');
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
name: core_logging
|
||||
description: 日志与崩溃上报的统一入口(AppLogger / CrashReporter),含敏感信息脱敏。
|
||||
publish_to: none
|
||||
version: 0.1.0
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.0
|
||||
|
||||
dependencies:
|
||||
core_foundation: ^0.1.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_riverpod: ^3.3.2
|
||||
logger: ^2.7.0
|
||||
sentry_flutter: ^9.26.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints: ^6.0.0
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:core_foundation/core_foundation.dart';
|
||||
import 'package:core_logging/core_logging.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('scrubber', () {
|
||||
test('敏感 key 整体抹掉', () {
|
||||
final Map<String, Object?> out = scrubMap(<String, Object?>{
|
||||
'accessToken': 'eyJhbGciOi...',
|
||||
'Authorization': 'Bearer abc',
|
||||
'ticket': 'T-123',
|
||||
'path': '/api/v1/stores',
|
||||
});
|
||||
|
||||
expect(out['accessToken'], redacted);
|
||||
expect(out['Authorization'], redacted);
|
||||
expect(out['ticket'], redacted);
|
||||
expect(out['path'], '/api/v1/stores');
|
||||
});
|
||||
|
||||
test('手机号走掩码而不是整体抹掉', () {
|
||||
expect(scrubMap(<String, Object?>{'phone': '13812345678'})['phone'], '138****5678');
|
||||
expect(maskPhone('123'), '***');
|
||||
});
|
||||
|
||||
test('嵌套结构递归处理', () {
|
||||
final Map<String, Object?> out = scrubMap(<String, Object?>{
|
||||
'user': <String, Object?>{'userId': 'u1', 'password': 'p'},
|
||||
'list': <Object?>[
|
||||
<String, Object?>{'refreshToken': 'r'},
|
||||
],
|
||||
});
|
||||
|
||||
final Map<String, Object?> user = out['user']! as Map<String, Object?>;
|
||||
expect(user['userId'], 'u1');
|
||||
expect(user['password'], redacted);
|
||||
final List<Object?> list = out['list']! as List<Object?>;
|
||||
expect((list.first! as Map<String, Object?>)['refreshToken'], redacted);
|
||||
});
|
||||
|
||||
test('原始 Map 不被修改', () {
|
||||
final Map<String, Object?> src = <String, Object?>{'token': 'x'};
|
||||
scrubMap(src);
|
||||
expect(src['token'], 'x');
|
||||
});
|
||||
|
||||
test('URL 去掉 query 和 fragment', () {
|
||||
expect(scrubUrl('https://h5.example.com/p?ticket=abc&x=1#frag'), 'https://h5.example.com/p');
|
||||
});
|
||||
|
||||
test('URL 解析失败时整条抹掉,而不是原样返回', () {
|
||||
expect(scrubUrl('http://[bad'), redacted);
|
||||
});
|
||||
});
|
||||
|
||||
group('LogBuffer', () {
|
||||
test('超出容量丢最旧的', () {
|
||||
final LogBuffer buffer = LogBuffer(capacity: 3);
|
||||
for (int i = 0; i < 5; i++) {
|
||||
buffer.add(LogRecord(level: 'I', message: '$i', timestamp: DateTime(2026)));
|
||||
}
|
||||
expect(buffer.length, 3);
|
||||
expect(buffer.recent().map((LogRecord r) => r.message), <String>['2', '3', '4']);
|
||||
});
|
||||
|
||||
test('recent 默认只取 30 条', () {
|
||||
final LogBuffer buffer = LogBuffer();
|
||||
for (int i = 0; i < 500; i++) {
|
||||
buffer.add(LogRecord(level: 'I', message: '$i', timestamp: DateTime(2026)));
|
||||
}
|
||||
expect(buffer.length, 500);
|
||||
expect(buffer.recent().length, 30);
|
||||
expect(buffer.recent().first.message, '470');
|
||||
});
|
||||
});
|
||||
|
||||
group('LoggerAppLogger', () {
|
||||
AppEnv envOf(AppFlavor flavor) => AppEnv(
|
||||
flavor: flavor,
|
||||
apiBaseUrl: 'https://example.com',
|
||||
enableLog: true,
|
||||
sentryDsn: '',
|
||||
h5AllowedHosts: const <String>{},
|
||||
);
|
||||
|
||||
test('prod 只收 warning 及以上', () {
|
||||
final LogBuffer buffer = LogBuffer();
|
||||
final AppLogger log = LoggerAppLogger(env: envOf(AppFlavor.prod), buffer: buffer);
|
||||
|
||||
log
|
||||
..d('debug')
|
||||
..i('info')
|
||||
..w('warn')
|
||||
..e('error');
|
||||
|
||||
expect(buffer.recent().map((LogRecord r) => r.message), <String>['warn', 'error']);
|
||||
});
|
||||
|
||||
test('dev 收全部级别', () {
|
||||
final LogBuffer buffer = LogBuffer();
|
||||
LoggerAppLogger(env: envOf(AppFlavor.dev), buffer: buffer)
|
||||
..d('debug')
|
||||
..i('info');
|
||||
|
||||
expect(buffer.length, 2);
|
||||
});
|
||||
|
||||
test('结构化 data 进缓冲前已脱敏', () {
|
||||
final LogBuffer buffer = LogBuffer();
|
||||
LoggerAppLogger(
|
||||
env: envOf(AppFlavor.uat),
|
||||
buffer: buffer,
|
||||
).i('login', data: <String, Object?>{'phone': '13812345678'});
|
||||
|
||||
expect(buffer.recent().single.message, contains('138****5678'));
|
||||
expect(buffer.recent().single.message, isNot(contains('13812345678')));
|
||||
});
|
||||
});
|
||||
|
||||
group('throwTestException', () {
|
||||
AppEnv envOf(AppFlavor flavor) => AppEnv(
|
||||
flavor: flavor,
|
||||
apiBaseUrl: 'https://example.com',
|
||||
enableLog: false,
|
||||
sentryDsn: '',
|
||||
h5AllowedHosts: const <String>{},
|
||||
);
|
||||
|
||||
test('dev 抛测试异常', () {
|
||||
expect(() => throwTestException(envOf(AppFlavor.dev)), throwsA(isA<Exception>()));
|
||||
});
|
||||
|
||||
test('prod 拒绝调用', () {
|
||||
expect(() => throwTestException(envOf(AppFlavor.prod)), throwsA(isA<StateError>()));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user