- Introduced a new document outlining SDK version locking, static analysis, formatting, generated artifacts management, branching and commit conventions, and CI gate checks. - Updated README to include the new conventions document. - Modified API design to use numeric error codes instead of strings, with a dedicated ErrorCode object for better maintainability. - Adjusted global exception handling to return numeric error codes. - Updated tests to reflect changes in error code handling.
443 lines
30 KiB
Markdown
443 lines
30 KiB
Markdown
# 13. 可观测性与埋点
|
||
|
||
## 为什么单独一篇
|
||
|
||
PRD §21.4 和 §22.1 有明确要求(主链路 Trace ID、H5 打开/关闭/失败事件、关键业务审计日志、9 类埋点事件),但 01-12 里完全没有落点。同时,**App 侧的可观测性是排查线上问题唯一的手段**——后端有 ELK 可以查日志,App 装在几百家门店的员工手机上,没有上报就等于全盲。
|
||
|
||
这一篇定三件事:**崩溃上报**、**日志规范**、**埋点规范**。
|
||
|
||
## 决策
|
||
|
||
| 项 | 决策 |
|
||
|---|---|
|
||
| 崩溃上报 | **[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),写入本地日志并作为崩溃上报的自定义字段 |
|
||
|
||
## 一、崩溃上报:Sentry
|
||
|
||
### 为什么不是 Bugly
|
||
|
||
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` 还原。
|
||
|
||
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](./backend/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](./backend/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 与审计日志](./backend/08-observability.md)
|
||
- [PRD §21.4 可观测性 / §22.1 埋点](./Architecture-Diagram/202606-Continental-Retail-APP-PRD.md)
|