refactor: remove CrashReporter and related logging components

- Removed CrashReporter interface and its implementation, SentryCrashReporter.
- Deleted log buffering functionality and associated LogBuffer class.
- Updated LoggerAppLogger to eliminate dependencies on removed components.
- Simplified logging structure to focus on AppLogger and sensitive data scrubbing.
- Adjusted AppEnv to remove Sentry-related configurations.
- Refactored tests to align with the new logging architecture.
This commit is contained in:
Guangfei.Zhao
2026-09-03 11:39:04 +08:00
parent d786fc31ff
commit 782fba80bc
39 changed files with 192 additions and 1152 deletions
@@ -3,7 +3,7 @@ library;
/// 业务代码唯一可见的埋点接口。
///
/// 理由和 `CrashReporter` 一样:测试里能 mock,`feature_*` 不多一条对三方
/// 这样测试里能 mock,`feature_*` 不多一条对三方
/// SDK 的直接依赖。**另外它也是采购未落地时的缓冲**——接口先定、事件方案
/// 先做,实现类换成一个最小的 `POST /api/v1/events/batch` 也只改一个文件。
///
@@ -6,7 +6,7 @@
/// 11 的 `switchStore` / `logout` 伪代码直接 `ref.read` 了
/// `webViewSessionProvider`(core_webview)、`appDatabaseProvider`(core_storage)、
/// `prefsProvider`(core_storage)、`goRouterProvider`(core_router)、
/// `crashReporterProvider`(core_logging)、`analyticsProvider`(core_analytics)。
/// `analyticsProvider`(core_analytics)。
///
/// 但 01 的依赖约束里,`core_auth` 一条 `core_*` 出边都没有(连允许的三条例外
/// 都是别人指向它)。照抄伪代码会把 core_auth 变成整个 core 层的汇聚点,
@@ -72,9 +72,8 @@ abstract interface class SessionScopedStore {
/// 会话变化时需要同步的观测 / 埋点侧。
///
/// 由 app 层用 core_logging 的 `CrashReporter` 和 core_analytics 的 `Analytics`
/// 实现。**漏了这一步的表现是切店后的崩溃和埋点还挂在旧门店名下**,按门店维度
/// 分析时数据是错的,而且错得很隐蔽(11 步骤 8)。
/// 由 app 层用 core_analytics 的 `Analytics` 实现。漏了这一步会让切店后的埋点
/// 仍挂在旧门店名下,按门店维度分析时数据会悄悄出错。
abstract interface class SessionObserver {
/// 门店上下文已更新。
void onStoreChanged(StoreContext store);
@@ -91,7 +91,6 @@ void main() {
flavor: AppFlavor.dev,
apiBaseUrl: 'https://example.test',
enableLog: false,
sentryDsn: '',
h5AllowedHosts: <String>{},
),
);
+15 -23
View File
@@ -22,41 +22,36 @@ enum AppFlavor {
/// **所有环境差异都收敛在这一个类里**,业务代码里不允许出现
/// `if (kDebugMode)` 或 `if (host.contains('uat'))` 这类判断。
///
/// 值来自 `--dart-define-from-file=env/{flavor}.json`(见 08)。构建时不传这个
/// 参数会得到空的 baseUrl,[fromDartDefine] 会直接抛错而不是让 App 带着空配置跑起来——
/// 这种错误必须在启动瞬间暴露,而不是等第一个网络请求 404。
/// 值来自随 App 打包的 `env/{flavor}.json`。配置无效时会在启动瞬间暴露,
/// 而不是等第一个网络请求 404。
class AppEnv {
const AppEnv({
required this.flavor,
required this.apiBaseUrl,
required this.enableLog,
required this.sentryDsn,
required this.h5AllowedHosts,
});
/// 从 `--dart-define-from-file` 注入的值构造。
///
/// [flavor] 由各自的入口文件(main_dev.dart / main_uat.dart / main_prod.dart)
/// 硬编码传入,而不是从 dart-define 读——这样"用 dev 的入口配了 prod 的 json"
/// 这种事故在代码里看得见。
factory AppEnv.fromDartDefine({required String flavor}) {
const apiBaseUrl = String.fromEnvironment('API_BASE_URL');
const enableLog = bool.fromEnvironment('ENABLE_LOG');
const sentryDsn = String.fromEnvironment('SENTRY_DSN');
const h5AllowedHosts = String.fromEnvironment('H5_ALLOWED_HOSTS');
/// 从已解析的环境 JSON 构造,并在启动期验证所有必填字段。
factory AppEnv.fromConfig({required String flavor, required Map<String, Object?> values}) {
final Object? apiBaseUrl = values['API_BASE_URL'];
final Object? enableLog = values['ENABLE_LOG'];
final Object? h5AllowedHosts = values['H5_ALLOWED_HOSTS'];
if (apiBaseUrl.isEmpty) {
throw StateError(
'API_BASE_URL 为空。启动时必须带上 --dart-define-from-file=env/$flavor.json,'
'见 README「怎么跑起来」。',
);
if (apiBaseUrl is! String || apiBaseUrl.trim().isEmpty) {
throw StateError('环境 $flavor 的 API_BASE_URL 必须是非空字符串。');
}
if (enableLog is! bool) {
throw StateError('环境 $flavor 的 ENABLE_LOG 必须是布尔值。');
}
if (h5AllowedHosts is! String) {
throw StateError('环境 $flavor 的 H5_ALLOWED_HOSTS 必须是字符串。');
}
return AppEnv(
flavor: AppFlavor.parse(flavor),
apiBaseUrl: apiBaseUrl,
enableLog: enableLog,
sentryDsn: sentryDsn,
h5AllowedHosts: _parseHosts(h5AllowedHosts),
);
}
@@ -75,9 +70,6 @@ class AppEnv {
/// 是否输出网络日志和 debug 级日志。prod 必须为 false(见 13)。
final bool enableLog;
/// 为空表示不启用 Sentry(dev 默认不上报,避免把开发期噪音混进线上数据)。
final String sentryDsn;
/// H5 域名白名单。WebView 只允许加载这些域名及其子域,见 10。
final Set<String> h5AllowedHosts;
@@ -44,12 +44,48 @@ void main() {
flavor: AppFlavor.dev,
apiBaseUrl: 'https://api-dev.conti.com',
enableLog: true,
sentryDsn: '',
h5AllowedHosts: <String>{'f6.conti.com'},
);
expect(env.isProd, isFalse);
expect(env.flavorSuffixVisible, isTrue);
expect(env.h5AllowedHosts, contains('f6.conti.com'));
});
test('从内置环境配置读取字段并规范化 H5 域名', () {
final AppEnv env = AppEnv.fromConfig(
flavor: 'uat',
values: <String, Object?>{
'API_BASE_URL': 'https://api-uat.conti.com',
'ENABLE_LOG': true,
'H5_ALLOWED_HOSTS': ' H5.Conti.com, shop.conti.com ',
},
);
expect(env.flavor, AppFlavor.uat);
expect(env.apiBaseUrl, 'https://api-uat.conti.com');
expect(env.enableLog, isTrue);
expect(env.h5AllowedHosts, <String>{'h5.conti.com', 'shop.conti.com'});
});
test('缺失或类型错误的内置配置在启动期失败', () {
expect(
() => AppEnv.fromConfig(
flavor: 'dev',
values: <String, Object?>{'API_BASE_URL': '', 'ENABLE_LOG': true, 'H5_ALLOWED_HOSTS': ''},
),
throwsStateError,
);
expect(
() => AppEnv.fromConfig(
flavor: 'dev',
values: <String, Object?>{
'API_BASE_URL': 'https://api-dev.conti.com',
'ENABLE_LOG': 'true',
'H5_ALLOWED_HOSTS': '',
},
),
throwsStateError,
);
});
});
}
+3 -11
View File
@@ -1,19 +1,11 @@
/// 日志与崩溃上报的统一入口。来源:conti-docs/13-observability-analytics.md。
/// 日志与敏感信息脱敏的统一入口。
///
/// 边界约定:
/// - 业务代码只用 [AppLogger] / [CrashReporter] 两个接口,**不直接 import
/// `logger` 或 `sentry_flutter`**,也不用 `print` / `debugPrint`;
/// - 全仓库只有本包的 `sentry_*.dart` 和 `app/bootstrap.dart` 可以见到
/// Sentry SDK。
/// - 业务代码只用 [AppLogger],不直接 import `logger`,也不用 `print` /
/// `debugPrint`。
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';
@@ -1,38 +0,0 @@
/// 路由面包屑观察者。来源: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>'}');
}
}
@@ -1,56 +0,0 @@
/// 崩溃上报门面。来源: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>{},
}) {}
}
@@ -1,57 +0,0 @@
/// 内存环形缓冲——崩溃时的"黑匣子"。来源: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();
}
@@ -5,7 +5,6 @@ import 'package:core_foundation/core_foundation.dart';
import 'package:logger/logger.dart';
import 'app_logger.dart';
import 'log_buffer.dart';
import 'scrubber.dart';
/// 日志级别,按严重程度递增。
@@ -33,14 +32,12 @@ enum LogSeverity {
/// 三条硬约束(13 §二):
/// 1. **prod 不输出到控制台**——Android 的 `logcat` 是全局可读的,门店设备上
/// 这不是理论风险;
/// 2. 所有内容进环形缓冲前**必须过一遍脱敏器**,因为缓冲会随崩溃外发;
/// 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})
LoggerAppLogger({required AppEnv env, Logger? logger})
: _minSeverity = _severityFor(env.flavor),
// enableLog 是 dart-define 里的开关,prod 即使误开也不打控制台。
_console = env.enableLog && !env.isProd,
@@ -64,9 +61,6 @@ class LoggerAppLogger implements AppLogger {
AppFlavor.prod => LogSeverity.warning,
};
/// 崩溃时随事件外发的"黑匣子"。与 `beforeSend` 读的必须是同一个实例。
final LogBuffer buffer;
final LogSeverity _minSeverity;
final bool _console;
final Logger _logger;
@@ -98,17 +92,6 @@ class LoggerAppLogger implements AppLogger {
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:
@@ -4,14 +4,6 @@ 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());
/// 全局日志入口。
///
@@ -21,8 +13,3 @@ final Provider<LogBuffer> logBufferProvider = Provider<LogBuffer>((Ref ref) => L
final Provider<AppLogger> appLoggerProvider = Provider<AppLogger>(
(Ref ref) => const NoopAppLogger(),
);
/// 全局崩溃上报入口。默认空实现,同 [appLoggerProvider]。
final Provider<CrashReporter> crashReporterProvider = Provider<CrashReporter>(
(Ref ref) => const NoopCrashReporter(),
);
+3 -3
View File
@@ -1,7 +1,7 @@
/// 脱敏器。来源:13 §二「脱敏」、PRD §21.3。
/// 脱敏器。来源:PRD §21.3。
///
/// 这个文件是全仓库唯一的脱敏实现,`AppLogger`、Sentry 的两个钩子、以及
/// 网络层的日志拦截器都必须走这里,不允许各写各的。
/// 这个文件是全仓库唯一的脱敏实现,`AppLogger` 与网络层的日志拦截器都必须
/// 走这里,不允许各写各的。
library;
/// 命中即整体替换为 [redacted] 的 key(大小写不敏感)。
@@ -1,71 +0,0 @@
/// `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 {
// 上报链路自身的异常不能外溢到业务流程。
}
}());
}
}
@@ -1,43 +0,0 @@
/// 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),
};
};
}
@@ -1,17 +0,0 @@
/// 上报链路的自检入口。来源: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 接入自检:这是一条人为触发的测试异常');
}
+1 -2
View File
@@ -1,5 +1,5 @@
name: core_logging
description: 日志与崩溃上报的统一入口(AppLogger / CrashReporter),含敏感信息脱敏。
description: 应用日志与敏感信息脱敏。
publish_to: none
version: 0.1.0
resolution: workspace
@@ -13,7 +13,6 @@ dependencies:
sdk: flutter
flutter_riverpod: 3.2.1
logger: ^2.7.0
sentry_flutter: ^9.26.0
dev_dependencies:
flutter_lints: ^6.0.0
@@ -53,85 +53,19 @@ void main() {
});
});
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>()));
test('各环境均可创建日志器', () {
for (final AppFlavor flavor in AppFlavor.values) {
final AppLogger log = LoggerAppLogger(env: envOf(flavor));
log.i('login', data: <String, Object?>{'phone': '13812345678'});
}
});
});
}