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
+1 -7
View File
@@ -96,8 +96,6 @@ coverage-gate:
- >
flutter build apk
--flavor $FLAVOR
-t lib/main_$FLAVOR.dart
--dart-define-from-file=env/$FLAVOR.json
$EXTRA_ARGS
artifacts:
paths:
@@ -122,12 +120,8 @@ android-prod:
<<: *android-build
variables:
FLAVOR: prod
# release 包必须混淆 + 拆符号表,否则 Sentry 上的堆栈是一堆十六进制地址。
# --split-debug-info 的产物要留着,sentry_dart_plugin 靠它还原堆栈。
# release 包必须混淆 + 拆符号表,符号表保留供后续排障使用。
EXTRA_ARGS: --release --obfuscate --split-debug-info=build/symbols
after_script:
# SENTRY_AUTH_TOKEN 只从 CI 变量读,仓库里任何文件都不许出现它。
- cd app && dart run sentry_dart_plugin
artifacts:
paths:
- app/build/app/outputs/flutter-apk/*.apk
+8 -12
View File
@@ -25,16 +25,12 @@ melos run gen
# 3. 跑起来
cd app
flutter run --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
flutter run --flavor dev
```
`--flavor` / `-t` / `--dart-define-from-file` **三个参数缺一不可**:
- 少了 `--dart-define-from-file`,`AppEnv.fromDartDefine` 会在启动瞬间抛错。
这是故意的——带着空 baseUrl 跑起来,问题会在第一个请求 404 时才暴露。
- 少了 `-t`,Flutter 会去找 `lib/main.dart`,本仓库没有这个文件。
VS Code / Android Studio 用户建议把三条 flavor 配进 `launch.json`。
项目只有一个 Dart 入口 `lib/main.dart`。`--flavor dev|uat|prod` 仍用于选择
Android/iOS 的原生包变体,同时 Flutter 会通过 `appFlavor` 选择对应的内置环境
配置。未传 flavor 时默认使用 dev,因此 DevEco 的普通 HAP 构建无需额外入口配置。
## 环境与工具链版本
@@ -100,10 +96,10 @@ melos run test # flutter test --coverage
```powershell
. .\enable-ohos.ps1
cd app
flutter build hap --debug -t lib\main_dev.dart
flutter build hap --debug
```
构建产物为未签名 debug HAP:`app/ohos/entry/build/default/outputs/default/entry-default-unsigned.hap`。这不会创建新的 Dart `main` 文件;现有环境入口仍是 `lib/main_dev.dart`、`lib/main_uat.dart` 和 `lib/main_prod.dart`。HAP 编译通过不代表 ArkTS 原生桥接、扫码或真机安装已经完成,边界见 [docs/07-native-integration.md](docs/07-native-integration.md)。
构建产物为未签名 debug HAP:`app/ohos/entry/build/default/outputs/default/entry-default-unsigned.hap`。Hvigor 默认使用现有的 `lib/main.dart`,并加载 dev 环境配置。HAP 编译通过不代表 ArkTS 原生桥接、扫码或真机安装已经完成,边界见 [docs/07-native-integration.md](docs/07-native-integration.md)。
`analyze` 带 `--fatal-infos`。不加等于没加 lint——绝大多数 riverpod_lint 规则
报的是 info 级。
@@ -118,7 +114,7 @@ flutter build hap --debug -t lib\main_dev.dart
app/ 壳工程。唯一知道所有包的地方:环境注入、启动编排、路由聚合
packages/
core_foundation/ AppEnv / AppException 体系 / ApiCode ← 叶子包,谁都能依赖
core_logging/ AppLogger / CrashReporter / 脱敏
core_logging/ AppLogger / 脱敏
core_analytics/ Analytics 接口 + 事件常量表
core_storage/ Prefs(KV)。Drift 暂缓,见 SCAFFOLD-NOTES
core_auth/ AppSession 四态 / TokenStorage / SessionNotifier
@@ -179,5 +175,5 @@ flutter analyze → info: depend_on_referenced_packages
模板。现在调 `startScan` 会抛 `MissingPluginException`,属预期。
- **神策 SDK 未采购**:`analyticsProvider` 是 `NoopAnalytics`。接入时注意必须在
用户同意隐私政策之后再初始化。
- **env/*.json 里全是占位域名**,`SENTRY_DSN` 全空,待运维确认。
- **env/*.json 里全是占位域名**,待运维确认。
- **CI 镜像名未定**,`.gitlab-ci.yml` 里标了 TODO(ops)。
+1 -2
View File
@@ -1,7 +1,6 @@
{
"_comment": "dev 环境。构建时必须带 --dart-define-from-file=env/dev.json,否则 AppEnv.fromDartDefine 会直接抛错。所有地址都是占位符,TODO(ops) 待运维确认真实域名。",
"_comment": "dev 环境。未指定 flavor 的启动(包括 DevEco 默认 HAP 构建)使用此配置。所有地址都是占位符,TODO(ops) 待运维确认真实域名。",
"API_BASE_URL": "https://api-dev.example.com",
"ENABLE_LOG": true,
"SENTRY_DSN": "",
"H5_ALLOWED_HOSTS": "h5-dev.example.com"
}
+1 -2
View File
@@ -1,7 +1,6 @@
{
"_comment": "prod 环境。ENABLE_LOG 必须为 false(13:生产不写控制台、不落日志文件)。TODO(ops) 待运维确认真实域名与 Sentry DSN。",
"_comment": "prod 环境。ENABLE_LOG 必须为 false,生产不写控制台日志。TODO(ops) 待运维确认真实域名。",
"API_BASE_URL": "https://api.example.com",
"ENABLE_LOG": false,
"SENTRY_DSN": "",
"H5_ALLOWED_HOSTS": "h5.example.com"
}
-1
View File
@@ -2,6 +2,5 @@
"_comment": "uat 环境。TODO(ops) 待运维确认真实域名与 Sentry DSN。",
"API_BASE_URL": "https://api-uat.example.com",
"ENABLE_LOG": true,
"SENTRY_DSN": "",
"H5_ALLOWED_HOSTS": "h5-uat.example.com"
}
+11 -2
View File
@@ -7,7 +7,7 @@
//
// 跑法:
// flutter test integration_test/app_test.dart \
// --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
// --flavor dev
// ---------------------------------------------------------------------------
import 'package:app/bootstrap.dart';
@@ -20,7 +20,16 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('冷启动后停在登录页', (WidgetTester tester) async {
await bootstrap(AppEnv.fromDartDefine(flavor: 'dev'));
await bootstrap(
AppEnv.fromConfig(
flavor: 'dev',
values: <String, Object?>{
'API_BASE_URL': 'https://api-dev.example.com',
'ENABLE_LOG': true,
'H5_ALLOWED_HOSTS': 'h5-dev.example.com',
},
),
);
await tester.pumpAndSettle();
// 没有 token → SessionUnauthenticated → redirect 到 /login(04)。
+3 -4
View File
@@ -62,7 +62,7 @@ Xcode 自己维护的二进制风格文本,手写会在下一次 Xcode 打开
6. 验证:
```bash
cd app
flutter build ios --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
flutter build ios --flavor dev
```
三个 flavor 各跑一次,确认桌面上能同时装下三个图标、名字不同。
@@ -71,6 +71,5 @@ Xcode 自己维护的二进制风格文本,手写会在下一次 Xcode 打开
- **CocoaPods 和 flavor 无关**:`Podfile` 不需要改,pod 是按 target 装的,
不是按 configuration。但新增 configuration 后要跑一次 `pod install`,否则
会报 `Unable to find a configuration named 'Debug-dev'`。
- **`--flavor` 和 `-t` 必须同时给**。只给 `--flavor dev` 会用默认的
`lib/main.dart`——这个文件在本仓库里**不存在**(入口是 `main_dev.dart`),
报错信息是找不到文件,和 flavor 看不出关系。
- 项目固定使用 `lib/main.dart`。`--flavor` 除了选择 Scheme,还会让启动代码
选择同名的内置环境配置;不要再额外传 `-t` 或 `--dart-define-from-file`。
+7 -49
View File
@@ -1,6 +1,6 @@
/// 三个入口共用的启动编排。
/// 唯一入口共用的启动编排。
///
/// 来源:08(多环境)、13(Sentry / 日志)、12(全局错误)、03(Riverpod 装配)。
/// 来源:08(多环境)、12(全局错误)、03(Riverpod 装配)。
library;
import 'package:app/src/app_widget.dart';
@@ -20,12 +20,11 @@ import 'package:feature_auth/feature_auth.dart';
import 'package:feature_home/feature_home.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
/// TODO(app): 换成 package_info_plus 读真实版本,现在写死会在灰度期骗人。
const String _appVersion = '1.0.0+1';
/// 全部三个 main_*.dart 都只调这一个函数。
/// 唯一 `main.dart` 调用的启动函数。
///
/// ---------------------------------------------------------------------------
/// 这个函数是**整个仓库唯一一处知道所有包的地方**。各个 core_* 只声明自己需要
@@ -40,14 +39,7 @@ Future<void> bootstrap(AppEnv env) async {
// 装一次全局单例,供拿不到 Ref 的 TokenRefresher 用;重复调用会抛错。
AppEnv.install(env);
final LogBuffer buffer = LogBuffer();
final AppLogger logger = LoggerAppLogger(env: env, buffer: buffer);
// dsn 为空(dev 默认)时连 SDK 都不初始化:开发期的噪音不该混进线上数据。
final bool sentryEnabled = env.sentryDsn.isNotEmpty;
final CrashReporter reporter = sentryEnabled
? const SentryCrashReporter()
: const NoopCrashReporter();
final AppLogger logger = LoggerAppLogger(env: env);
final Prefs prefs = Prefs();
final ClientInfo clientInfo = ClientInfo(
@@ -62,13 +54,11 @@ Future<void> bootstrap(AppEnv env) async {
Widget buildApp() {
return ProviderScope(
observers: <ProviderObserver>[ErrorObserver(logger: logger, reporter: reporter)],
observers: <ProviderObserver>[ErrorObserver(logger: logger)],
overrides: [
// --- 基础设施:值已经在上面造好了,直接注入 -----------------------
appEnvProvider.overrideWithValue(env), // core_foundation
appLoggerProvider.overrideWithValue(logger), // core_logging
logBufferProvider.overrideWithValue(buffer), // core_logging(和 beforeSend 同一实例)
crashReporterProvider.overrideWithValue(reporter), // core_logging
prefsProvider.overrideWithValue(prefs), // core_storage
clientInfoProvider.overrideWithValue(clientInfo), // core_network 端口
// --- 端口:接口在 core_*,实现在能依赖 core_network 的这一层 --------
@@ -87,10 +77,7 @@ Future<void> bootstrap(AppEnv env) async {
),
sessionObserversProvider.overrideWith(
(Ref ref) => <SessionObserver>[
AppSessionObserver(
analytics: ref.watch(analyticsProvider),
reporter: ref.watch(crashReporterProvider),
),
AppSessionObserver(analytics: ref.watch(analyticsProvider)),
],
),
@@ -98,16 +85,8 @@ Future<void> bootstrap(AppEnv env) async {
appRoutesProvider.overrideWith(
(Ref ref) => <RouteBase>[...buildAuthRoutes(), ...buildHomeRoutes()],
),
navigatorObserversProvider.overrideWith(
(Ref ref) => <NavigatorObserver>[
CrashBreadcrumbObserver(ref.watch(crashReporterProvider)),
],
),
routeReporterProvider.overrideWith(
(Ref ref) => AppRouteReporter(
logger: ref.watch(appLoggerProvider),
reporter: ref.watch(crashReporterProvider),
),
(Ref ref) => AppRouteReporter(logger: ref.watch(appLoggerProvider)),
),
// --- 日志出口:core_network 只知道"往这里写字符串" -------------------
@@ -123,26 +102,5 @@ Future<void> bootstrap(AppEnv env) async {
);
}
if (!sentryEnabled) {
runApp(buildApp());
return;
}
await SentryFlutter.init(
(SentryFlutterOptions options) {
options.dsn = env.sentryDsn;
options.environment = env.flavor.name;
options.release = 'conti-retail-app@$_appVersion';
options.tracesSampleRate = env.isProd ? 0.1 : 1.0;
// 合规红线:不自动带用户 IP / 请求头 / cookie。
options.sendDefaultPii = false;
options.beforeBreadcrumb = scrubBreadcrumb;
options.beforeSend = buildScrubEvent(buffer);
options.debug = false;
},
// 用 appRunner 而不是自己写 FlutterError.onError:SentryFlutter 已经在
// 里面装好了 Flutter / PlatformDispatcher / Zone 三层钩子,再手写一遍
// 会**每个异常上报两次**(13)。
appRunner: () => runApp(buildApp()),
);
}
+15
View File
@@ -0,0 +1,15 @@
/// 应用唯一 Dart 入口。
///
/// Android、iOS 与 Web 用 `--flavor dev|uat|prod` 选择环境;未提供 flavor
/// 的平台(例如 DevEco 的默认 HAP 构建)统一使用 dev。
library;
import 'package:app/bootstrap.dart';
import 'package:app/src/bundled_app_env.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show appFlavor;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await bootstrap(await loadBundledAppEnv(flavor: appFlavor));
}
-14
View File
@@ -1,14 +0,0 @@
/// dev 环境入口。
///
/// 跑法(08):
/// ```
/// flutter run --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
/// ```
/// flavor 名写死在这里而不是从 dart-define 读——"用 dev 的入口配了别的环境的
/// json"这种事故必须在代码里看得见。
library;
import 'package:app/bootstrap.dart';
import 'package:core_foundation/core_foundation.dart';
Future<void> main() => bootstrap(AppEnv.fromDartDefine(flavor: 'dev'));
-14
View File
@@ -1,14 +0,0 @@
/// prod 环境入口。
///
/// 跑法(08):
/// ```
/// flutter run --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json
/// ```
/// flavor 名写死在这里而不是从 dart-define 读——"用 prod 的入口配了别的环境的
/// json"这种事故必须在代码里看得见。
library;
import 'package:app/bootstrap.dart';
import 'package:core_foundation/core_foundation.dart';
Future<void> main() => bootstrap(AppEnv.fromDartDefine(flavor: 'prod'));
-14
View File
@@ -1,14 +0,0 @@
/// uat 环境入口。
///
/// 跑法(08):
/// ```
/// flutter run --flavor uat -t lib/main_uat.dart --dart-define-from-file=env/uat.json
/// ```
/// flavor 名写死在这里而不是从 dart-define 读——"用 uat 的入口配了别的环境的
/// json"这种事故必须在代码里看得见。
library;
import 'package:app/bootstrap.dart';
import 'package:core_foundation/core_foundation.dart';
Future<void> main() => bootstrap(AppEnv.fromDartDefine(flavor: 'uat'));
+29
View File
@@ -0,0 +1,29 @@
/// 随 App 打包的环境配置加载器。
library;
import 'dart:convert';
import 'package:core_foundation/core_foundation.dart';
import 'package:flutter/foundation.dart' show FlutterError;
import 'package:flutter/services.dart';
/// 根据 Flutter 的 flavor 加载对应环境配置;未指定时默认开发环境。
Future<AppEnv> loadBundledAppEnv({String? flavor}) async {
final String selectedFlavor = flavor ?? AppFlavor.dev.name;
final String assetPath = 'env/$selectedFlavor.json';
try {
final String jsonObj = await rootBundle.loadString(assetPath);
final decoded = jsonDecode(jsonObj);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('根节点必须是 JSON 对象');
}
return AppEnv.fromConfig(flavor: selectedFlavor, values: decoded.cast<String, Object?>());
} on StateError {
rethrow;
} on FormatException catch (error) {
throw StateError('环境配置 $assetPath 格式错误:${error.message}');
} on FlutterError catch (error) {
throw StateError('无法加载环境配置 $assetPath:${error.message}');
}
}
+2 -6
View File
@@ -10,7 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
///
/// ---------------------------------------------------------------------------
/// 它是**兜底**,不是主路径:UI 该显示的错误由 `AsyncValueView` 负责,这里只
/// 负责"这个异常有没有人处理过"之外的另一件事——落日志和上报。
/// 负责"这个异常有没有人处理过"之外的另一件事——落日志。
///
/// 三类要**主动排除**,否则线上告警会被噪音淹没:
/// - [BusinessException]:后端明确告诉我们"这个操作不允许",是预期内的流程
@@ -20,14 +20,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
/// ---------------------------------------------------------------------------
final class ErrorObserver extends ProviderObserver {
/// 构造。
ErrorObserver({required this.logger, required this.reporter});
ErrorObserver({required this.logger});
/// 日志出口。
final AppLogger logger;
/// 崩溃上报出口。
final CrashReporter reporter;
@override
void providerDidFail(ProviderObserverContext context, Object error, StackTrace stackTrace) {
final String name = context.provider.name ?? context.provider.runtimeType.toString();
@@ -42,6 +39,5 @@ final class ErrorObserver extends ProviderObserver {
}
logger.e('provider 失败 $name', error: error, stackTrace: stackTrace);
reporter.report(error, stackTrace, extra: <String, String>{'provider': name});
}
}
+5 -21
View File
@@ -1,4 +1,4 @@
/// 会话事件的旁路接线:埋点身份、崩溃上报的用户上下文、路由错误上报。
/// 会话事件的旁路接线:埋点身份与路由错误日志。
///
/// 这些都是 core_auth 声明的端口(`session_ports.dart` / `core_router/ports.dart`)
/// 的实现——core_auth 不能依赖 core_analytics / core_logging,所以实现落在这里。
@@ -9,26 +9,20 @@ import 'package:core_auth/core_auth.dart';
import 'package:core_logging/core_logging.dart';
import 'package:core_router/core_router.dart';
/// 把会话变化广播给埋点和崩溃上报。
/// 把会话变化广播给埋点。
class AppSessionObserver implements SessionObserver {
/// 构造。
const AppSessionObserver({required this.analytics, required this.reporter});
const AppSessionObserver({required this.analytics});
/// 埋点。
final Analytics analytics;
/// 崩溃上报。
final CrashReporter reporter;
@override
void onUserIdentified(UserContext user) {
analytics.identify(user.userId);
analytics.registerSuperProperties(<String, Object?>{
AnalyticsSuperProperty.roleCode: user.roleCode,
});
// 只传 userId,不传手机号——Sentry 侧 sendDefaultPii = false 的前提就是
// 我们自己也不往里塞 PII。
reporter.setUser(user.userId);
}
@override
@@ -37,7 +31,6 @@ class AppSessionObserver implements SessionObserver {
analytics.registerSuperProperties(<String, Object?>{
AnalyticsSuperProperty.storeId: store.storeId,
});
reporter.setTag('storeId', '${store.storeId}');
}
@override
@@ -46,7 +39,6 @@ class AppSessionObserver implements SessionObserver {
analytics.track(AnalyticsEvent.logout, <String, Object?>{AnalyticsParam.reason: reason.name});
// 门店设备是共用的:不 reset,下一个人的数据会串到上一个人身上。
analytics.reset();
reporter.clearUser();
}
@override
@@ -57,26 +49,18 @@ class AppSessionObserver implements SessionObserver {
}
}
/// 路由未命中时上报。
/// 路由未命中时记录日志。
class AppRouteReporter implements RouteReporter {
/// 构造。
const AppRouteReporter({required this.logger, required this.reporter});
const AppRouteReporter({required this.logger});
/// 日志。
final AppLogger logger;
/// 崩溃上报。
final CrashReporter reporter;
@override
void onRouteNotFound(String location) {
// 记路径不记 query——H5 相关路径的 query 里带票据(13 §脱敏)。
final String path = Uri.tryParse(location)?.path ?? location;
logger.w('路由未命中: $path');
reporter.report(
StateError('route not found'),
StackTrace.current,
extra: <String, String>{AnalyticsParam.path: path},
);
}
}
+4 -11
View File
@@ -26,7 +26,6 @@ dependencies:
flutter_riverpod: 3.2.1
intl: any
native_scan: ^0.1.0
sentry_flutter: ^9.26.0
dev_dependencies:
flutter_lints: ^6.0.0
@@ -34,19 +33,13 @@ dev_dependencies:
sdk: flutter
integration_test:
sdk: flutter
sentry_dart_plugin: ^3.4.0
# 只为测试拿 InMemorySharedPreferencesAsync,不在 lib/ 里出现。
shared_preferences_platform_interface: ^2.4.2
flutter:
uses-material-design: true
generate: true
# release 构建后由 CI 调用 `dart run sentry_dart_plugin` 上传符号表。
# 见 08 §release 构建 和 13 §崩溃上报。
sentry:
upload_debug_symbols: true
upload_source_maps: false
project: conti-retail-app
org: continental
# auth_token 只从 CI 变量 SENTRY_AUTH_TOKEN 读,绝不写进仓库
assets:
- env/dev.json
- env/uat.json
- env/prod.json
+1 -1
View File
@@ -37,7 +37,7 @@ final goRouterProvider = Provider<GoRouter>((ref) {
final router = GoRouter(
navigatorKey: rootNavigatorKey, // 全局 dialog / 顶层跳转需要它
initialLocation: '/home',
observers: [NavigationObserver(ref.read(crashReporterProvider))], // 崩溃前的页面路径,见 13
observers: const [], // 路由观测由 app 层按需注入
redirect: (context, state) {
// read 不是 watch:这里只要当前值,订阅由下面的 listen 负责
final auth = ref.read(authStateProvider);
+1 -1
View File
@@ -238,7 +238,7 @@ app/ohos/entry/src/main/ets/
**延后抽取独立 OHOS plugin。** 只有出现第二个 OHOS App 的复用需求、扫描实现和契约已稳定、Flutter_OH plugin 注册/模板已成熟并经验证,或 SDK 化收益明确超过维护成本时,才评审将 ArkTS 从 `app/ohos/entry` 抽到 `packages/native_scan/ohos`。在此之前,不添加 `ohos:` plugin 声明或额外 `ohos/` 目录。
**SDK 收敛条件。** 当前 Android/iOS 使用 Flutter 3.44.9,OHOS 使用 Flutter_OH 3.41.10-ohos-1.0.0;Flutter_OH 上游文档计划在 **2026 年 9 月**提供 Flutter 3.44.x / Dart 3.12.x 对齐版本。实际 Tag/Commit 发布并验证可用后,可评审统一基线。升级前必须以该候选 SDK 完成依赖解析、Pigeon 与 Riverpod 生成物核验、`flutter build hap --debug -t lib\main_dev.dart` 和真机原生能力回归;不能仅因计划日期或版本号相同就替换工具链。
**SDK 收敛条件。** 当前 Android/iOS 使用 Flutter 3.44.9,OHOS 使用 Flutter_OH 3.41.10-ohos-1.0.0;Flutter_OH 上游文档计划在 **2026 年 9 月**提供 Flutter 3.44.x / Dart 3.12.x 对齐版本。实际 Tag/Commit 发布并验证可用后,可评审统一基线。升级前必须以该候选 SDK 完成依赖解析、Pigeon 与 Riverpod 生成物核验、`flutter build hap --debug` 和真机原生能力回归;不能仅因计划日期或版本号相同就替换工具链。
## 使用规则
+16 -33
View File
@@ -17,11 +17,11 @@ App 侧维护 **3 个 flavor:`dev` / `uat` / `prod`**,环境划分与现有
## 使用规则
- 每个 flavor 对应一个独立的 Dart 入口文件(`main_dev.dart`/`main_uat.dart`/`main_prod.dart`),三者都只是设置好环境标识后调用同一个共享的 `bootstrap()` 启动函数,不允许在入口文件里写业务逻辑分支。
- 环境相关的可变配置(API base URL、是否开启日志等,见 [05-networking.md](./05-networking.md) 的 `appEnvProvider`)通过 `--dart-define-from-file=env/{flavor}.json` 注入,不写死在代码里、也不用 `if (flavor == 'dev')` 这种运行时字符串判断来分支配置。
- 全项目只保留 `lib/main.dart` 一个 Dart 入口。Flutter 的 `appFlavor` 自动对应 `--flavor`,启动代码据此加载打包的 `env/{flavor}.json`;未传 flavor 时默认 dev,DevEco 不需要额外入口配置。
- 环境配置只包含可公开随包发布的 API 地址、日志开关与 H5 host;不在 Dart 代码中写死,也不要求开发者为入口文件额外传 `-t` 或 `--dart-define-from-file`。
- `env/*.json` 只包含非敏感配置(API 地址等);密钥类配置(如第三方 SDK App Key)通过 CI 变量在构建时注入,不提交进仓库。
- Android 侧用 Gradle `productFlavors` 区分 `applicationIdSuffix`/图标/`versionNameSuffix`;iOS 侧用对应的 xcconfig + Scheme 区分 `Bundle Identifier`/图标;两端 flavor 名称必须完全一致(`dev`/`uat`/`prod`),不允许两端用不同命名。
- CI 流水线阶段固定为:`melos run analyze` → `melos run test` → 按 flavor `flutter build apk/ipa --flavor {flavor} --dart-define-from-file=env/{flavor}.json` → 上传对应分发渠道。`prod` flavor 的构建触发条件是打 tag,不是每次 push 都触发(避免误发生产包)。
- CI 流水线阶段固定为:`melos run analyze` → `melos run test` → 按 flavor `flutter build apk/ipa --flavor {flavor}` → 上传对应分发渠道。`prod` flavor 的构建触发条件是打 tag,不是每次 push 都触发(避免误发生产包)。
## 用 `applicationIdSuffix` 而不是覆盖 `applicationId`
@@ -41,15 +41,13 @@ productFlavors {
```bash
fvm flutter build appbundle \
--flavor prod --target lib/main_prod.dart \
--dart-define-from-file=env/prod.json \
--flavor prod \
--obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG
```
- `--obfuscate` 混淆 Dart 符号名,`--split-debug-info` 把调试符号剥离到单独目录(同时显著减小包体)。
- **两个参数必须一起用**,只写 `--obfuscate` 不写 `--split-debug-info` 会被工具链拒绝。
- **符号表必须归档,并且构建完立刻上传到 Sentry**:混淆后崩溃堆栈是不可读的乱码。CI 在 build 之后紧跟一条 `fvm dart run sentry_dart_plugin`,把 Dart 符号表、Android mapping、iOS dSYM 一起传上去(见 [13-observability-analytics.md](./13-observability-analytics.md))。**上传时的 `release` 必须和 App 里 `options.release` 严格一致**,对不上的表现是"传了但堆栈还是混淆的",且后台不报错。
- 同时把 `build/symbols/` 按 `版本号+构建号` 归档为 CI artifact 保留至少 1 年,作为 Sentry 侧数据过期或服务不可用时的兜底。**丢了符号表 = 那个版本的所有线上崩溃永远无法定位**,这是个不可逆的失误。
- 符号表必须按 `版本号+构建号` 归档为 CI artifact 保留至少 1 年,供后续排障或未来选择的崩溃平台使用。
- 符号表目录按版本号区分(用 tag 或 `versionName+versionCode`),不能所有版本堆一个目录。
## 版本号规则
@@ -113,8 +111,7 @@ build_ios_uat:
stage: build
tags: [macos] # 只有 mac runner 有这个 tag
script:
- fvm flutter build ipa --flavor uat --target lib/main_uat.dart \
--dart-define-from-file=env/uat.json --export-options-plist=ios/ExportOptions-uat.plist
- fvm flutter build ipa --flavor uat --export-options-plist=ios/ExportOptions-uat.plist
```
## 内测分发渠道:Firebase 在国内可达性存疑
@@ -154,13 +151,11 @@ Android 的 flavor 源集目录(`src/{flavor}/`)会自动生效;iOS 没有
- **iOS 构建 runner 方案(A/B/C 选哪个)**——阻塞 iOS 发版,优先级最高。
- 内测分发渠道的最终选型与托管位置。
- 是否引入 Firebase(影响崩溃上报选型,见 [13-observability-analytics.md](./13-observability-analytics.md))。
- Android 上架渠道清单(华为/小米/OPPO/vivo 各家应用市场是否都要上,各家的加固/隐私合规要求不同)。
## 参考链接
- [Flutter 官方 Flavors 文档](https://docs.flutter.dev/deployment/flavors)
- [--dart-define-from-file 官方说明](https://docs.flutter.dev/deployment/flavors#configuration-approaches)
- [Flutter: 混淆 Dart 代码](https://docs.flutter.dev/deployment/obfuscate)
- [Android: 从命令行构建并签名](https://developer.android.com/build/building-cmdline)
- [fastlane match(证书管理)](https://docs.fastlane.tools/actions/match/)
@@ -179,8 +174,7 @@ Android 的 flavor 源集目录(`src/{flavor}/`)会自动生效;iOS 没有
1. **Android `productFlavors`**:在 `android/app/build.gradle` 里声明多套 `applicationId`/`versionNameSuffix`/资源目录,编译时用 `--flavor` 选择其中一套。
2. **iOS Scheme + xcconfig**:iOS 没有 Gradle 那样的单文件配置,而是通过 Xcode 里多个 Build Configuration(对应不同的 `.xcconfig` 文件设置 Bundle ID 等)+ 多个 Scheme 组合实现同样的效果,`flutter build ipa --flavor xxx` 背后就是选中同名 Scheme。
3. **Dart 入口文件(`main_xxx.dart`)**:flavor 决定的是"编译出什么样的原生外壳",Dart 代码本身默认只有一个 `main.dart` 入口——项目约定用多个入口文件对应各 flavor,让每个 flavor 能设置不同的启动参数(比如传给 `bootstrap()` 一个环境枚举)。
4. **`--dart-define-from-file`**:flavor 解决的是原生层面的差异(图标、包名),但 API 地址这类 Dart 侧读取的配置,用编译期注入的 JSON 文件解决,避免打包进一个写死 `http://dev-api...` 的字符串常量。
3. **Dart 入口与环境配置**:Dart 固定从 `main.dart` 启动,Flutter 自动把 `--flavor` 暴露为 `appFlavor`;启动代码按其加载内置 JSON 配置。未提供 flavor 时用 dev,适配 DevEco 的默认构建。
### 使用示例
@@ -225,30 +219,21 @@ android {
}
```
共享启动入口 + 各 flavor 的 Dart 入口文件:
唯一 Dart 入口:
```dart
// lib/bootstrap.dart —— 三个 flavor 共用的启动逻辑
Future<void> bootstrap(AppEnv env) async {
runApp(ProviderScope(
overrides: [appEnvProvider.overrideWithValue(env)],
child: const App(),
));
// lib/main.dart
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await bootstrap(await loadBundledAppEnv(flavor: appFlavor));
}
```
```dart
// lib/main_dev.dart
void main() => bootstrap(AppEnv.fromDartDefine(name: 'dev'));
```
构建命令:
```bash
fvm flutter build apk \
--flavor dev \
--target lib/main_dev.dart \
--dart-define-from-file=env/dev.json
--flavor dev
```
GitLab CI 片段(衔接现有 Runner;注意 Android 和 iOS 必须落到不同的 runner 上):
@@ -266,7 +251,7 @@ build_android_dev:
script:
- melos run analyze
- melos run test
- flutter build apk --flavor dev --target lib/main_dev.dart --dart-define-from-file=env/dev.json
- flutter build apk --flavor dev
artifacts:
paths: [build/app/outputs/flutter-apk/app-dev-release.apk]
rules:
@@ -276,8 +261,7 @@ build_android_prod:
<<: *flutter_base
stage: build
script:
- flutter build appbundle --flavor prod --target lib/main_prod.dart
--dart-define-from-file=env/prod.json
- flutter build appbundle --flavor prod
--build-name=${CI_COMMIT_TAG#v} --build-number=$CI_PIPELINE_ID
--obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG
artifacts:
@@ -292,8 +276,7 @@ build_ios_prod:
stage: build
tags: [macos] # 必须是 mac runner,Linux runner 跑不了,见上文「iOS 构建链路」
script:
- fvm flutter build ipa --flavor prod --target lib/main_prod.dart
--dart-define-from-file=env/prod.json
- fvm flutter build ipa --flavor prod
--build-name=${CI_COMMIT_TAG#v} --build-number=$CI_PIPELINE_ID
--obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG
rules:
+2 -4
View File
@@ -145,8 +145,7 @@ Future<void> switchStore(int targetStoreId) async {
// ── 7. 记住这次选择,供下次冷启动预选 ────────────────
await ref.read(prefsProvider).setInt('last_store_id', newStore.storeId);
// ── 8. 同步观测上下文(见 13)────────────────────────
ref.read(crashReporterProvider).setTag('storeId', '${newStore.storeId}');
// ── 8. 同步埋点上下文 ──────────────────────────────────
ref.read(analyticsProvider).registerSuperProperties({'storeId': newStore.storeId});
// 切店事件本身由后端从 /stores/{id}/switch 的接口日志出,客户端不重复上报,见 13
}
@@ -158,7 +157,7 @@ Future<void> switchStore(int targetStoreId) async {
| 3 在 5 之前 | H5 页面里可能有在途请求。先 `about:blank` 停掉,再换上下文,否则旧门店的 H5 请求会带着新门店的票据回来 |
| 4 在 5 之前 | 缓存表带 `storeId`(见 06),但**清理和新上下文之间不能有窗口期**:如果先落新上下文,provider 立刻失效并重新请求,可能在清理完成前就把新数据写进去,然后被 `clearBusinessCache()` 一起删掉 |
| 6 在 5 之后 | 清栈时目标页面(首页)要用新上下文渲染 |
| 8 在 5 之后 | 观测上下文要和业务上下文保持一致;漏了这一步的表现是**切店后的崩溃和埋点还挂在旧门店名下**,按门店维度分析时数据是错的,而且错得很隐蔽 |
| 8 在 5 之后 | 埋点上下文要和业务上下文保持一致;漏了这一步会让切店后的埋点还挂在旧门店名下,按门店维度分析时数据会悄悄出错 |
**关于步骤 0(未完成写操作)**:切店时用户可能正在提交订单或上传图片。默认策略是**阻止切换并提示**,而不是静默取消——取消一个已经发出去的下单请求,客户端不知道服务端到底成没成。`pendingWriteProvider` 由发起写操作的 feature 自己注册/注销。
@@ -190,7 +189,6 @@ Future<void> logout({LogoutReason reason = LogoutReason.userInitiated}) async {
ref.read(analyticsProvider)
..track(AnalyticsEvent.logout, {'reason': reason.name}) // 报完再 reset,顺序不能反
..reset();
ref.read(crashReporterProvider).setUser('');
// 6. 兜底:清掉所有 provider 缓存
ref.invalidate(...); // 或在 ProviderScope 层重建,见下文
+14 -434
View File
@@ -1,442 +1,22 @@
# 13. 可观测性与埋点
## 为什么单独一篇
PRD §21.4 和 §22.1 有明确要求(主链路 Trace ID、H5 打开/关闭/失败事件、关键业务审计日志、9 类埋点事件),但 01-12 里完全没有落点。同时,**App 侧的可观测性是排查线上问题唯一的手段**——后端有 ELK 可以查日志,App 装在几百家门店的员工手机上,没有上报就等于全盲。
这一篇定三件事:**崩溃上报**、**日志规范**、**埋点规范**。
# 13. 日志与埋点
## 决策
| 项 | 决策 |
|---|---|
| 崩溃上报 | **[sentry_flutter](https://pub.dev/packages/sentry_flutter) `^9.26.0`**(官方 verified publisher),配套 [sentry_dart_plugin](https://pub.dev/packages/sentry_dart_plugin) `^3.4.0` 上传符号表 |
| Sentry 部署形态 | **自建优先**(`sentry.io` SaaS 是跨境上报),**待确认** |
| 本地日志 | **[logger](https://pub.dev/packages/logger) `^2.7.0`**,封装在 `core_logging` 的 `AppLogger` 后面 |
| 客户端埋点 | **[神策 `sensors_analytics_flutter_plugin`](https://pub.dev/packages/sensors_analytics_flutter_plugin) `^4.2.3`**(官方 verified publisher `sensorsdata.cn`;团队过往项目用过,本项目待正式确认) |
| 业务埋点 | **以后端为主**,客户端只补后端看不到的那部分 |
| 链路关联 | 客户端生成 `X-Trace-Id`(见 05),写入本地日志并作为崩溃上报的自定义字段 |
首期不接入任何崩溃上报 SDK,也不向第三方传输异常、用户或门店上下文。业务代码通过
`core_logging` 输出经过脱敏的本地日志;行为分析继续通过 `core_analytics` 的抽象接口接入。
## 一、崩溃上报:Sentry
## 日志规则
### 为什么不是 Bugly
- `AppLogger` 是唯一日志入口;请求日志和业务日志都必须经 `scrubber.dart` 脱敏。
- dev 输出 debug 级别日志,uat 输出 info 及以上,prod 不输出控制台日志。
- 日志不持久化、不上传;不保留无消费者的崩溃黑匣子缓存。
- Provider 失败和未知路由仅记录脱敏日志。业务拒绝、登录失效和主动取消属于预期流程,
不作为错误日志噪音处理。
Bugly 是团队过往项目用过的方案,国内可达性没问题,本来是很自然的默认选项。**否掉它的理由只有一条,但这一条是决定性的:Bugly 没有上传 Dart 符号表的能力。**
## 埋点
- Flutter App 的**绝大多数异常是 Dart 异常**(`setState` 期间抛错、null check、JSON 解析失败),不是原生崩溃。Bugly 只能把它们当"自定义异常"收下,存成一段字符串堆栈。
- [08-build-flavors.md](./08-build-flavors.md) 要求 release 必须 `--obfuscate --split-debug-info`。两者相加的结果是:**线上占比最大的那一半崩溃,在 Bugly 后台是一串读不出来的混淆符号**,只能人工把堆栈拷出来跑 `flutter symbolize` 还原。
`Analytics` 仍由 app 层注入。会话建立时设置用户与角色维度,切店时更新门店维度,登出时
先记录登出事件再 reset,避免共用门店设备上的用户上下文串联。
Bugly 在原生侧(Java/Kotlin 异常、SIGSEGV、ANR、iOS crash)确实做得好,能自动符号化。但它强的正好是我们占比小的那一半。
其余差别一并记录在此,作为决策存档:
| | 腾讯 Bugly | Sentry |
|---|---|---|
| **Dart 异常堆栈还原** | **做不到** | **做得到**(`sentry_dart_plugin` 自动上传) |
| Flutter 官方 SDK | **没有**,只有 Android/iOS 原生 SDK,pub.dev 上只有 `flutter_bugly` 1.1.1、`bugly_pro_flutter` 0.4.21 两个 unverified 社区插件 | **有**,官方维护、13 天前刚发版 |
| 原生崩溃 | 强项,自动符号化 | 支持,mapping/dSYM 由同一个插件上传 |
| 接入成本 | 要自己写 `native_crash`(Pigeon + 几十行 Kotlin/Swift) | `pubspec.yaml` 加两行 |
| 国内可达性 | 无问题 | **自建无问题;SaaS 是跨境上报**,见下 |
| 运维成本 | 无 | 自建的话有(存储、升级、告警) |
| 账号/合同 | 本项目**没有**现成的,要新申请 | 同样要新建 |
代价是运维:Sentry 这条路把"接入成本"换成了"部署成本"。这是这次选型唯一真正付出的东西。
注意最后一行:**本项目在两边都没有既有账号或合同**,所以"沿用现成的"这个通常最有分量的理由,在这次选型里不成立——两条路的启动成本都要从零算。
**连带影响:不再需要 `native_crash` 这个包。** [07-native-integration.md](./07-native-integration.md) 里的 Pigeon 包只剩 `native_scan` / `native_media`。
### 唯一还没定的:自建还是 SaaS
这一条**必须在开工前定**,它决定的不只是可达性,还有合规:
- **自建(推荐)**:崩溃数据不出境,门店网络下上报可靠,长期成本可控。代价是要一套内网 K8s/VM 资源和运维承接方。
- **`sentry.io` SaaS**:零运维,但崩溃报告里带着 `userId`、`storeId`、面包屑和日志片段,属于**数据出境**,要走合规评估;同时门店网络访问境外服务的丢报率无法预估。
需要在讨论时明确的:有没有可用的内网资源、谁运维、以及法务对崩溃数据出境的口径。**在结论出来之前,`SENTRY_DSN` 走 `--dart-define-from-file`(见 08),代码里不写死任何地址——换 DSN 不需要改一行代码。**
### 依赖与初始化
```yaml
dependencies:
sentry_flutter: ^9.26.0
dev_dependencies:
sentry_dart_plugin: ^3.4.0
```
```dart
// main.dart —— 崩溃上报必须在最早期初始化,晚一步就漏掉启动期崩溃
await SentryFlutter.init(
(options) {
options.dsn = env.sentryDsn; // 来自 --dart-define-from-file,见 08
options.environment = env.flavorName; // dev / uat / prod 分开看,否则测试数据污染线上崩溃率
options.release = '${env.appVersion}+${env.buildNumber}'; // 必须和符号表归档对得上
options.tracesSampleRate = 0.0; // 首版不开性能追踪,见下
options.sendDefaultPii = false; // 关键:默认不采集 IP / 请求头 / 用户信息
options.beforeBreadcrumb = scrubBreadcrumb; // 见「脱敏」
options.beforeSend = scrubEvent;
},
appRunner: () => runApp(ProviderScope(
retry: (_, __) => null,
observers: [ErrorObserver()], // 见 12
child: const ContiApp(),
)),
);
```
**`appRunner` 不是可选写法。** 传了它,Sentry 会自己接管 `FlutterError.onError` 和 `PlatformDispatcher.instance.onError` 并把 `runApp` 放进受保护的 error zone;**这时候再手写一遍这两个回调,结果是同一个异常上报两次**,线上崩溃数直接翻倍,是这个 SDK 最常见的接入错误。
业务代码仍然只依赖 `core_logging` 暴露的 `CrashReporter` 接口,不直接 import `sentry_flutter`:
```dart
// packages/core_logging/lib/src/crash_reporter.dart
abstract interface class CrashReporter {
void setUser(String userId);
void setTag(String key, String value);
void leaveBreadcrumb(String message);
void report(Object error, StackTrace? stack, {Map<String, String> extra = const {}});
}
```
这一层不是为了"将来可能换 Sentry"——**是为了测试里能直接 mock 掉,不必真的初始化 SDK**,以及让 `feature_*` 不多一条对三方 SDK 的直接依赖(见 [01-project-structure.md](./01-project-structure.md) 的依赖规则)。
### 符号表:唯一必须打通的一步
`sentry_dart_plugin` 包装 `sentry-cli`,构建后一条命令把 Dart 符号表、Android mapping、iOS dSYM 一起传上去:
```yaml
# pubspec.yaml
sentry:
upload_debug_symbols: true
upload_source_maps: false # 不做 Web
project: conti-retail-app
org: continental
# auth_token 走 CI 环境变量 SENTRY_AUTH_TOKEN,不写进仓库
```
```bash
# CI:build 之后立刻跑,见 08
fvm flutter build appbundle --flavor prod --target lib/main_prod.dart \
--dart-define-from-file=env/prod.json \
--obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG
fvm dart run sentry_dart_plugin
```
三条硬约束:
- **`options.release` 必须和上传符号表时的 release 严格一致**,对不上的表现是"符号表传上去了,堆栈还是混淆的"——这是接入 Sentry 最常见的坑,且后台不会报错。统一由 `versionName+versionCode` 生成(见 08 的版本号规则)。
- **上传步骤必须在 CI 里、紧跟 build**,不能靠人工。漏传一次,那个版本的崩溃就永久读不出来。
- **本地符号表归档照旧保留**(08 要求 ≥1 年)。Sentry 能自动还原之后它不再是唯一手段,但仍是 Sentry 服务出问题/数据过期时的兜底。
### 必须关掉的默认行为
Sentry 的默认配置面向公网 C 端产品,有几项在门店场景下不能开:
| 项 | 结论 |
|---|---|
| `sendDefaultPii` | **false**。开了会自动带上 IP、请求头(含 `Authorization`)、用户信息 |
| Session Replay / 截图(`attachScreenshot`) | **关闭**。收银、经营分析页面上有金额和客户信息 |
| `attachViewHierarchy` | 关闭。控件树里会出现输入框内容 |
| `tracesSampleRate` | **0.0**,首版不开性能追踪。接口耗时后端已有(见 backend/08),开了只是多一份跨境流量 |
| HTTP 面包屑里的 URL | **必须脱敏**:H5 URL 的 query 里带着 `ticket`,原样进面包屑等于把 token 发出去,见「脱敏」一节 |
### 用户与门店上下文
会话状态变化时同步(见 [11-store-context-and-session.md](./11-store-context-and-session.md)):
```dart
CrashReporter.instance
..setUser(session.user.userId) // 只传 ID,不传手机号/姓名
..setTag('storeId', '${session.store.storeId}')
..setTag('roleCode', session.user.roleCode)
..setTag('flavor', env.flavorName);
```
**`storeId` 一定要带。** 它能直接回答"这个崩溃是不是只发生在某几家门店"——门店设备型号和网络环境高度集中,很多崩溃是设备相关的,没有这个维度只能盲猜。
`traceId` 在网络相关的错误上报时作为自定义字段带上,这样一条崩溃能直接关联到后端 ELK 里的那次请求(见 [backend/08-observability.md](../../conti-backend/docs/08-observability.md))。
### 崩溃前的页面路径
崩溃报告里最有用的上下文之一是"崩之前用户在哪几个页面"。go_router 的 `observers` 挂一个 `NavigationObserver`(见 [04-routing.md](./04-routing.md)),把最近的路由变化写进环形缓冲,随崩溃一起上报:
```dart
class NavigationObserver extends NavigatorObserver {
NavigationObserver(this._reporter);
final CrashReporter _reporter;
@override
void didPush(Route route, Route? previous) =>
_reporter.leaveBreadcrumb('nav: ${route.settings.name}');
}
```
注意**记的是路由名不是完整 URL**——`/webview?target=X&ticket=...` 里带着票据(见脱敏一节)。同理再补三处业务关键节点:H5 启动/失败、门店切换、扫码。这三条链路最长、最容易出问题。
### 上报什么、不上报什么
- **上报**:未捕获的 Dart 异常、原生崩溃、ANR、Riverpod provider 抛出的异常(通过 `ErrorObserver`,即使 UI 已经优雅处理了——"用户看到了漂亮的错误页"和"不需要知道有多少人看到"是两回事,见 12)。
- **不上报**:`RequestCancelledException`(用户正常退出页面)、`UnauthorizedException`(正常的登出流程)。这两类是业务流程的一部分,上报只会把真正的崩溃淹掉。
### 验证接入真的成功了
崩溃上报最常见的失败模式是**静默不上报**——数据没传上来,但你以为 App 很稳定。所以:
- `core_logging` 暴露一个 `throwTestException()`,**只在 dev flavor 下可调**,每次发版前在 dev 上验证一遍 Android 和 iOS 都能在 Sentry 里看到。
- **同时验证堆栈是不是可读的**——这一步比"能收到"更容易漏。用 `--obfuscate` 打一个 release 包、跑一遍 `sentry_dart_plugin`、再触发一次异常,确认后台显示的是 Dart 文件名行号而不是 `_x12`。`release` 对不上的话就是这个表现,见上文。
- uat 环境跑一个迭代后,对一下"Sentry 上的错误数"和"埋点里的 `api_failed` 数量级",如果差得离谱说明有一侧漏了。
## 二、日志规范
### `AppLogger`
```dart
// packages/core_logging/lib/src/app_logger.dart
abstract interface class AppLogger {
void d(String message, {Map<String, Object?>? data});
void i(String message, {Map<String, Object?>? data});
void w(String message, {Object? error, StackTrace? stackTrace});
void e(String message, {Object? error, StackTrace? stackTrace});
}
```
各包**不直接用 `logger` 包,也不用 `print`/`debugPrint`**,统一注入 `AppLogger`。理由:将来换日志实现只改一处;同时 `print` 在 release 下不会被剥离,是一条实打实的信息泄漏通道。
### 级别与环境
| 环境 | 级别 | 输出 |
|---|---|---|
| dev | `debug` | 控制台,带颜色和调用栈 |
| uat | `info` | 控制台 + 内存环形缓冲(最近 500 条) |
| prod | `warning` | **不输出到控制台**,只进内存环形缓冲 + 随崩溃上报 |
**prod 不打控制台日志**:Android 上 `logcat` 是全局可读的,任何装了 adb 或第三方日志 App 的人都能看到。门店设备上这不是理论风险。
**内存环形缓冲**的作用是:崩溃时把最近 N 条日志一起传上去,相当于一个"黑匣子"。不落磁盘,App 退出即消失,避免日志文件成为新的泄漏面。实现上挂在 `beforeSend` 里作为 `contexts` 附加,单个事件体积有上限,所以实际带的是**最近 30 条**,不是全部 500 条。
### 脱敏(PRD §21.3「敏感字段脱敏」)
```dart
// packages/core_logging/lib/src/scrubber.dart
const _sensitiveKeys = {
'token', 'accessToken', 'refreshToken', 'ticket', 'password',
'code', // 短信验证码
'phone', 'mobile', 'idCard', 'bankCard',
};
String maskPhone(String v) => v.length >= 11 ? '${v.substring(0, 3)}****${v.substring(7)}' : '***';
```
规则:
- **请求/响应体不整体打日志**。只打 method、path、状态码、耗时、`code`、`traceId`。真要看 body 只在 dev 下开,且过一遍脱敏器。
- **`Authorization` 头永远不打**,一个字符都不打——打前 8 位也不行,那既足够辅助暴力破解,也足够在日志里认出是谁的 token。
- **H5 URL 打日志前必须去掉 query**:URL 里带着 `ticket`,整条打出去等于打 token。
- 崩溃上报前再做一遍同样的脱敏——环形缓冲里的日志会随崩溃一起传上去。
**同一个脱敏器要挂到 Sentry 的两个钩子上**,这是上文 `SentryFlutter.init` 里 `beforeBreadcrumb` / `beforeSend` 的实现:
```dart
// packages/core_logging/lib/src/sentry_scrubber.dart
Breadcrumb? scrubBreadcrumb(Breadcrumb? crumb, Hint hint) {
if (crumb == null) return null;
// SDK 自动记录的 HTTP 面包屑里 url 是完整的,query 里可能带 ticket / token
final url = crumb.data?['url'];
if (url is String) {
final u = Uri.tryParse(url);
crumb.data?['url'] = u == null ? '<redacted>' : u.replace(query: '').toString();
}
return crumb;
}
```
**`beforeBreadcrumb` 不能漏。** Sentry 默认会自动记录所有 HTTP 请求作为面包屑,我们在 05/10 里辛苦保证的"URL 不落日志",会被这条默认行为绕过去——它不走我们的 `AppLogger`。
### 不采集什么
出于合规(个人信息保护法「最小必要」原则)和 PRD §21.3:
| 项 | 结论 |
|---|---|
| 崩溃截图 / Session Replay / View Hierarchy | **关闭**。收银、经营分析页面上有金额和客户信息 |
| 精确位置 | 不采集。App 没有需要精确位置的功能 |
| IMEI / IDFA / MAC / AndroidID | **不采集**(见 05,`X-Device-Id` 用的是匿名安装 UUID)。**神策原生 SDK 默认会采集设备标识来生成 `distinct_id`,必须在初始化时逐项关掉**;Sentry 侧靠 `sendDefaultPii = false` |
| 通讯录、短信 | 不申请权限 |
| 用户输入的原文 | 不打日志(包括搜索关键词里可能出现的车牌、手机号) |
这份清单要和 App 的隐私政策(PRD §10.3,由 App Backend 下发)**逐条对齐**——隐私政策里没写的,代码里就不能采。**第三方 SDK 的默认采集行为是最容易在合规审查时出问题的地方**:神策和 Sentry 的隐私说明都要单独过一遍,并且要在**用户同意隐私政策之前不初始化**(两个 SDK 都支持延迟初始化),否则「同意前不采集」这条硬要求就破了。
## 三、埋点:以后端为主
**大部分业务埋点由后端从自己的请求日志和审计日志里出,客户端不重复做一遍。**
理由很直接:任何一个业务动作(登录、切店、下单、入库、打开 H5)都会打到 App Backend 的接口上,后端已经有 `traceId`、用户上下文、门店上下文和 `@Audited` 审计通道(见 [backend/08-observability.md](../../conti-backend/docs/08-observability.md))。客户端再报一遍,得到的是同一件事的两份数据——而且客户端那份还更不可靠(可能丢、可能延迟、可能被篡改)。
**这两份数据最好落到同一个地方。** 神策有服务端 SDK / 数据导入接口,后端把业务事件写进同一个神策项目的话,运营就能做「扫码失败的门店,后续下单转化率是不是更低」这种跨端漏斗;分成两套系统也能跑,但每次跨端分析都要人工对数。**这一条要和后端确认**,见待确认项。
### Metabase 不是神策的替代品
后端侧提到过 Metabase。**它和神策不冲突,也不是二选一**——两者根本不在一层:
| | 神策 | Metabase |
|---|---|---|
| 客户端采集 SDK | **有** | **没有**,它不采集任何数据 |
| 数据来源 | 自己的 SDK / 服务端导入 | 接已有的数据库、数仓 |
| 定位 | 采集 + 管道 + 分析平台 | BI / 看板层 |
Metabase 官网自己把 Mixpanel、PostHog、Amplitude 列为**上游集成**——由那些工具负责采集,Metabase 在导出的数据上出图。这就说明了它的位置。
所以合理的分工是:**神策收客户端事件;后端的业务埋点本来就在自己库里,Metabase 接上去出报表。** 后端如果已经在用 Metabase,那是个好消息而不是冲突信号——它意味着上面「两份数据落到一个地方」这条有了第二种解法:不把业务数据推进神策,而是反过来把神策的客户端事件导出到同一个库,用 Metabase 统一出图,还能直接和订单、门店主数据 join。哪一种更合适取决于后端的数仓现状,一并列进待确认项。
### 分工
| PRD §22.1 事件 | 谁来出 | 说明 |
|---|---|---|
| 登录成功/失败 | **后端** | 登录本身就是接口调用 |
| 首页曝光 | **后端** | 首页聚合接口的调用即曝光 |
| 门店切换 | **后端** | 切换接口 |
| 采购下单 | **后端** | |
| 入库成功 | **后端** | |
| 待办点击 | **后端** | 点击后会请求详情接口 |
| **扫码成功/失败** | **客户端** | 扫码是 App 原生实现(见 07),**不产生任何请求**,后端完全看不到 |
| **H5 关闭 / 异常** | **客户端** | 「打开」有 `/h5/launch` 请求后端能看到;**关闭、白屏、超时、加载失败后端看不到** |
| 客服点击 | **客户端** | 拨号、企微二维码是纯客户端行为 |
**客户端埋点的判据只有一条:这件事会不会产生一次后端请求?不会,才由客户端上报。**
### 客户端事件表
按上面的判据筛下来,客户端只需要这几个:
| 事件 | 触发 | 关键参数 |
|---|---|---|
| `scan_succeeded` / `scan_failed` | 扫码结果 | `mode`(barcode/vin/plate)、`durationMs`、`failReason` |
| `h5_closed` | H5 页关闭 | `target`、`stayDurationMs` |
| `h5_failed` | 白屏 / 超时 / 加载失败 | `target`、`errorCode`、`elapsedMs`、`traceId` |
| `h5_first_paint` | H5 首屏完成 | `target`、`ticketMs`(换票耗时)、`loadMs`(页面加载耗时) |
| `support_clicked` | 客服入口点击 | `channel`(hotline/dealer/o2o) |
| `api_failed` | 请求失败 | `path`、`code`、`httpStatus`、`traceId` |
| `app_cold_start` | 冷启动完成 | `durationMs` |
| `logout` | 登出 | `reason`(userInitiated / tokenExpired / sessionRevoked)。**被动登出没有对应的接口调用**,见 [11](./11-store-context-and-session.md) |
| `session_restore_failed` | 冷启动恢复会话失败 | 失败阶段(读 storage / me / stores)。卡在读 secure storage 时不产生任何网络请求 |
两条说明:
- **`h5_first_paint` 必须把耗时拆成 `ticketMs` 和 `loadMs` 两段**。合成一个数字的话,慢了不知道该找 App Backend / F6 / 还是网络——这是这个 App 里最长的一条跨系统链路,也是最容易互相甩锅的地方。
- **`api_failed` 客户端也要报**,虽然后端也能看到失败。因为**后端看不到"请求根本没发出去"和"响应没收到"**:超时、连接失败、DNS 失败、运营商劫持,这些在后端日志里要么完全没有记录,要么表现为一次正常的成功响应。门店网络不稳时这类失败占大头。
### 实现约定:神策 SDK,外面包一层
客户端埋点走**神策 `sensors_analytics_flutter_plugin`**:官方 verified publisher `sensorsdata.cn`,`4.2.3` 一个多月前发布,是当前维护中的官方插件——这在 pub.dev 上的国内三方 SDK 里不多见(对比 Bugly 那两个 unverified 社区插件)。**团队过往项目用过,事件模型和数据接入的坑踩过一遍**,这是选它最实在的理由。
#### 神策不是"开箱即用",这些活一样要干
先把预期摆正,否则排期一定会低估。**接了神策之后,下面这些工作量和自建一套上报是完全一样的**:
- **事件方案设计**——事件名、属性、口径对齐。这才是埋点的大头,跟用什么 SDK 无关。
- **`core_analytics` 的接口封装**(见下)。
- **接入点的编排**——超级属性什么时候注册、切店后重注册、登录/登出的 ID 关联。神策给了 API,但在哪调是我们的事(见 [11-store-context-and-session.md](./11-store-context-and-session.md) 的级联清单)。
- **私有化部署的运维**(如果走私有化)。
**神策真正替我们省掉的只有一件具体的事:客户端的可靠投递。** 原生 SDK 自带本地缓存、批量上报、弱网重传、后台 flush 和进程被杀后的补发。门店网络不稳,这个模块不能省,自己写的话是**容易写得看起来对、实际在丢数据**的那一类——丢了还不会有人发现。这一条就是选现成 SDK 的全部收益,其余都要照做。
#### 依赖与封装
```yaml
dependencies:
sensors_analytics_flutter_plugin: ^4.2.3
```
业务代码仍然只见 `core_analytics` 的接口,不直接 import 神策:
```dart
// packages/core_analytics/lib/src/analytics.dart
abstract interface class Analytics {
void track(String event, [Map<String, Object?> params = const {}]);
void registerSuperProperties(Map<String, Object?> props); // 公共属性,注册一次全局附加
void identify(String userId); // 登录成功后调
void reset(); // 登出时调
}
```
理由和 `CrashReporter` 一样:测试里能 mock,`feature_*` 不多一条对三方 SDK 的直接依赖。**另外它也是采购未落地时的缓冲**——接口先定、事件方案先做,实现类换成一个最小的 `POST /api/v1/events/batch` 也只改一个文件(代价就是上面那条可靠投递要自己补)。
接入约定:
- **公共属性用「超级属性」注册一次,不在每个调用点手写**:`storeId`、`roleCode`、`flavor`、`appVersion`、`buildNumber`。`storeId` 尤其重要——运营侧几乎所有分析都按门店维度看,靠每个调用点自己传一定会漏。**门店切换后必须重新注册**(见 [11-store-context-and-session.md](./11-store-context-and-session.md) 的级联清单)。
- **登录/登出走 `login()` / `logout()`**:登录成功后用后端的 `userId` 关联匿名 ID,登出时断开,否则同一台设备上换人登录的数据会串到一起(门店设备是共用的,这个场景一定会发生)。
- **埋点失败绝不能影响业务**:`track()` 内部 try-catch 兜住,任何异常只记日志不外抛。
- **dev/uat 与 prod 必须分开**——独立项目,或至少用不同的数据接收地址。共用一个项目的话,测试数据会直接污染运营报表,且事后无法剔除。
#### 全埋点(AutoTrack):只开启动/退出,其余关掉
神策的全埋点支持 `APP_START` / `APP_END` / `APP_CLICK` / `APP_VIEW_SCREEN` 四类。我们的结论:
| 类型 | 结论 |
|---|---|
| `APP_START` / `APP_END` | **开**。启动次数、使用时长是零成本拿到的基础指标 |
| `APP_CLICK` | **关**。Flutter 的控件树没有原生 `id`/`resource-name`,采上来的元素标识基本不可读,是纯噪音 |
| `APP_VIEW_SCREEN` | **关**。改用我们自己的 `NavigationObserver` 上报路由名——既更准,也**避免把 `/webview?target=X&ticket=...` 整条 URL 采上去**(见脱敏一节) |
“少采一点”在这里不是保守,是因为**采上来读不懂的数据比没有更糟**:它会让报表看起来有数据,实际没法用。
#### H5 内部的埋点不归我们
F6 的 H5 页面是外部系统,页面内部的行为埋点由 F6 自己负责。**客户端只报容器级事件**(打开/关闭/失败/首屏耗时),不往 WebView 里注入神策的 JS SDK——注进去就等于我们要为别人页面里的数据质量负责,而且 JSBridge 的能力清单([10-webview-h5.md](./10-webview-h5.md) 的 12 项)里也没有埋点这一项。
### 命名约定
`snake_case`,`对象_动作` 或 `对象_动作_结果`。结果类用过去式(`succeeded`/`failed`),动作类用现在式(`clicked`)。
事件名和参数名一旦上线**不再改**——改名意味着历史数据断裂,运营报表要重做。要加维度就加参数。事件名统一定义为常量(`AnalyticsEvent.scanSucceeded`),不允许在调用处写字符串字面量:拼写错误编译期发现不了,在报表里表现为"这个事件怎么没数据"。
## 四、性能指标
| 指标 | 怎么测 | 目标 |
|---|---|---|
| 冷启动到首帧 | `WidgetsBinding.instance.addTimingsCallback` | < 2s |
| 冷启动到首页可用 | `main()` → 首页数据渲染完成 | < 3s |
| H5 打开耗时 | `h5_first_paint` 的 `ticketMs + loadMs` | < 3s(PRD §21.1 要求有超时策略) |
| 接口耗时 | 后端侧统计即可,客户端不重复报 | P95 < 1s |
| 帧率 | 先不做自动采集,用 DevTools 人工测关键页面 | — |
**接口耗时不由客户端报**:后端有完整的请求日志和 Micrometer 指标(见 backend/08)。客户端唯一能补充的是"客户端观测到的耗时 - 服务端处理耗时 = 网络耗时",这个差值有价值但不是首版必须,先不做。
## 五、和后端审计日志的分工
`backend/08-observability.md` 已经有 `@Audited` 审计日志通道。**审计以服务端为准,客户端不做审计**——客户端日志可被篡改,不能作为审计依据。
| | 客户端埋点 | 服务端日志/审计 |
|---|---|---|
| 目的 | 补齐后端看不到的行为 | 业务分析、合规追溯 |
| 可信度 | 参考 | 权威 |
| 覆盖 | 纯客户端行为、请求失败 | 所有到达服务端的操作 |
## 待确认项
- **Sentry 是自建还是用 SaaS**——这是本篇最硬的阻塞项,决定可达性和数据出境合规口径。需要明确内网资源、运维承接方、法务意见。见上文「唯一还没定的」。
- Sentry 的 org/project 划分:dev/uat/prod 是三个 project 还是靠 `environment` 区分(建议 prod 单独一个 project,避免测试数据污染线上崩溃率告警)。
- **公司有没有在用的神策服务?** 本项目没有现成账号。有的话拿数据接收地址即可;**没有的话开通神策是采购流程,不是配置项**,周期可能比开发长。这一项是埋点唯一的外部依赖——**但它不阻塞开工**:事件方案设计和 `core_analytics` 接口先做,这两块工作量与最终用什么 SDK 无关。真的走不通,实现类换成最小的 `POST /api/v1/events/batch`,代价是可靠投递要自己补。
- 若确认用神策:数据接收地址是私有化部署还是神策云,以及 dev/uat/prod 的项目划分。地址走 `--dart-define-from-file`,代码里不写死。
- **客户端事件和后端业务数据怎么汇到一起**:是后端用神策服务端 SDK 写进同一个神策项目,还是把神策的客户端事件导出到后端数仓、统一用 Metabase 出图。取决于后端数仓现状和 Metabase 的实际使用情况,要和后端一起定。
- 神策原生 SDK 的默认设备信息采集项(`distinct_id` 的生成方式、是否取 AndroidID/IDFA),需逐项关闭并与隐私政策对齐(法务侧)。
- **后端的业务埋点写不写进同一个神策项目**(用神策服务端 SDK / 数据导入),还是留在自己的 ELK 里出报表。影响的是能不能做跨端漏斗分析。
- 后端从请求日志出业务埋点的具体口径(哪个接口对应哪个事件),需要和后端一起把 PRD §22.1 的 9 类事件逐条落到接口上。
- 神策和 Sentry 都要在**用户同意隐私政策之后**才初始化,具体的延迟初始化时机要和 `feature_auth` 的协议弹窗流程对齐。
- 性能指标目标值需在真机(门店常用的中低端 Android)实测后校准,上表是初始预期值。
## 参考链接
- [sentry_flutter | Dart package](https://pub.dev/packages/sentry_flutter)
- [sentry_dart_plugin | Dart package](https://pub.dev/packages/sentry_dart_plugin)(上传 Dart 符号表 / mapping / dSYM)
- [Sentry: Flutter Debug Symbols](https://docs.sentry.io/platforms/dart/guides/flutter/debug-symbols/)
- [Sentry: 自建(self-hosted)](https://develop.sentry.dev/self-hosted/)
- [sensors_analytics_flutter_plugin | Dart package](https://pub.dev/packages/sensors_analytics_flutter_plugin)
- [神策:Flutter 插件集成文档](https://manual.sensorsdata.cn/sa/docs/tech_sdk_client_flutter_plugin/v0300)
- [神策:Flutter 全埋点](https://manual.sensorsdata.cn/sa/docs/tech_sdk_client_flutter_auto_track/v0205)
- [Metabase](https://www.metabase.com/)(BI 层,非采集方案;官网把 Mixpanel/PostHog/Amplitude 列为上游采集集成)
- [Flutter: 混淆与 `flutter symbolize`](https://docs.flutter.dev/deployment/obfuscate)
- [logger | Dart package](https://pub.dev/packages/logger)
- [backend/08-observability.md:traceId 与审计日志](../../conti-backend/docs/08-observability.md)
- [PRD §21.4 可观测性 / §22.1 埋点](../../conti-docs/Architecture-Diagram/202606-Continental-Retail-APP-PRD.md)
如未来需要崩溃上报,须单独完成合规、数据字段、脱敏、符号表保存和 SDK 选型评审;不得
直接恢复本项目已删除的第三方 SDK 配置。
@@ -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'});
}
});
});
}
-88
View File
@@ -33,14 +33,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.2+1"
ansicolor:
dependency: transitive
description:
name: ansicolor
sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f"
url: "https://pub.dev"
source: hosted
version: "2.0.3"
args:
dependency: transitive
description:
@@ -407,14 +399,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.3"
globbing:
dependency: transitive
description:
name: globbing
sha256: "4f89cfaf6fa74c9c1740a96259da06bd45411ede56744e28017cc534a12b6e2d"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
go_router:
dependency: transitive
description:
@@ -463,14 +447,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
injector:
dependency: transitive
description:
name: injector
sha256: ed389bed5b48a699d5b9561c985023d0d5cc88dd5ff2237aadcce5a5ab433e4e
url: "https://pub.dev"
source: hosted
version: "3.0.0"
integration_test:
dependency: transitive
description: flutter
@@ -492,14 +468,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
jni:
dependency: transitive
description:
name: jni
sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1
url: "https://pub.dev"
source: hosted
version: "0.14.2"
json_annotation:
dependency: transitive
description:
@@ -644,22 +612,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
package_info_plus:
dependency: transitive
description:
name: package_info_plus
sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481"
url: "https://pub.dev"
source: hosted
version: "10.2.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
url: "https://pub.dev"
source: hosted
version: "4.1.0"
path:
dependency: transitive
description:
@@ -772,14 +724,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
properties:
dependency: transitive
description:
name: properties
sha256: "333f427dd4ed07bdbe8c75b9ff864a1e70b5d7a8426a2e8bdd457b65ae5ac598"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
pub_semver:
dependency: transitive
description:
@@ -844,30 +788,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.3"
sentry:
dependency: transitive
description:
name: sentry
sha256: c2ecd8abe82e63cdcb6947f71320612ced56e10ba94db7529d85cc02be47cb3b
url: "https://pub.dev"
source: hosted
version: "9.27.0"
sentry_dart_plugin:
dependency: transitive
description:
name: sentry_dart_plugin
sha256: da9c1d0b3c87a251bfc36301f16af090a88c2d59128fe9a6f908f5ac20340c97
url: "https://pub.dev"
source: hosted
version: "3.4.0"
sentry_flutter:
dependency: transitive
description:
name: sentry_flutter
sha256: ec89cc6ba939ca19155ea83900d9740a36544f50b3b6baf265518e3348fb0f50
url: "https://pub.dev"
source: hosted
version: "9.27.0"
shared_preferences:
dependency: transitive
description:
@@ -1041,14 +961,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.1"
system_info2:
dependency: transitive
description:
name: system_info2
sha256: b937736ecfa63c45b10dde1ceb6bb30e5c0c340e14c441df024150679d65ac43
url: "https://pub.dev"
source: hosted
version: "4.1.0"
term_glyph:
dependency: transitive
description: