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:
@@ -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
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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,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'});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user