app scaffold

This commit is contained in:
Guangfei.Zhao
2026-08-17 15:29:55 +08:00
commit 681688dfae
301 changed files with 18414 additions and 0 deletions
@@ -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 接入自检:这是一条人为触发的测试异常');
}