Files
conti-docs/flutter-app/13-observability-analytics.md
T

465 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 13. 可观测性与埋点
## 为什么单独一篇
PRD REQ-NFR-015(崩溃与错误上报)和第 6.8 节(埋点)有明确要求——主链路 Trace ID、H5 打开/关闭/失败事件、关键业务审计日志、埋点事件——但 01-12 里完全没有落点。同时,**App 侧的可观测性是排查线上问题唯一的手段**——后端有 ELK 可以查日志,App 装在几百家门店的员工手机上,没有上报就等于全盲。
这一篇定三件事:**崩溃上报**、**日志规范**、**埋点规范**。
## 决策
| 项 | 决策 |
|---|---|
| 崩溃上报 | **腾讯 Bugly**(原生崩溃 / ANR / 启动崩溃),通过自建的 `native_crash` Pigeon 包接入 |
| 错误明细与看板 | **神策自定义事件 `app_error` + 在神策上二次开发错误看板**,不引入独立崩溃平台 |
| release 混淆 | **不开 `--obfuscate`**,只 `--split-debug-info` 归档符号表,见下文「必须关掉混淆」 |
| 本地日志 | **[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),写入本地日志并作为崩溃/错误上报的自定义属性 |
## 一、崩溃上报:Bugly + 神策
### 分工
崩溃上报要收两类东西,它们的技术性质完全不同:
- **原生崩溃**SIGSEGV、ANR、iOS crash、启动期崩溃)—— 交给 **Bugly**。团队现成在用,国内可达性没有任何问题,原生侧自动符号化是它的强项。
- **Dart 异常**`setState` 期间抛错、null check、JSON 解析失败、未 catch 的 `Future`)—— 我们**自己捕获**,作为结构化的自定义事件 `app_error` 上报到**神策**,在神策上二次开发出错误看板(按 `errorType` / `route` / `storeId` / `appVersion` 分组看趋势和影响设备数)。
- 同一条 Dart 异常**再通过 Bugly 的自定义异常接口报一份**,这样 Bugly 后台的崩溃率是全量口径,不会因为"Dart 异常不在里面"而虚低。两边口径见下文「两边报同一件事,怎么不打架」。
不引入独立崩溃平台(Sentry 等),省掉一次采购、一套部署运维和一轮跨境数据合规评估。**代价在下一节,必须一起接受。**
### 必须关掉混淆
一个独立崩溃平台在这件事上唯一真正的优势,是能自动上传 Dart 符号表并在后台还原混淆堆栈。**Bugly 和神策都做不到** —— Bugly 只能把 Dart 异常当成一段字符串收下,神策更是只当事件属性存着。
如果继续按原计划用 `--obfuscate` 打 release 包,结果是**线上占比最大的那一半崩溃,堆栈是一串读不出来的 `_x12`**,每次排查都要人工把堆栈拷出来跑一遍 `flutter symbolize`。所以这里必须做一个连带决策:
| release 构建参数 | 堆栈可读性 | 结论 |
|---|---|---|
| `--obfuscate --split-debug-info` | 类名方法名全部混淆,必须 `flutter symbolize` 才能看 | ❌ 放弃(原计划) |
| **只 `--split-debug-info`** | **类名、方法名直接可读**`OrderRepository.submit`),行号被剥离到符号表,需要精确行号时再 `symbolize` | ✅ **采用** |
| 两个都不加 | 完全可读(带 `file:line`),但产物变大、Dart 符号全部留在包里 | ❌ 不采用 |
**这是本次选型真正付出的东西**:上报回来的堆栈能直接定位到出错的类和方法,够用;代价是 Dart 层的符号不再混淆,逆向门槛降了一档。这一条要和安全侧确认(见待确认项)——安全侧若坚持要混淆,就必须接受"每条 Dart 崩溃都要人工 symbolize"这个排查成本,并把 [08-build-flavors.md](./08-build-flavors.md) 的构建参数改回去。
**符号表照旧归档**(08 要求 ≥1 年)。关掉混淆后它不再是日常排查的必需品,但仍是拿精确行号的唯一手段,且丢了不可逆。
### 存档:为什么不另起一个崩溃平台
| | Bugly + 神策(采用) | 独立崩溃平台(如 Sentry) |
|---|---|---|
| **Dart 堆栈还原** | 做不到,靠"不混淆"绕开 | 做得到(插件自动上传符号表) |
| Flutter 官方 SDK | Bugly **没有**pub.dev 上只有 unverified 社区插件,要自己写 `native_crash` | 有,`pubspec.yaml` 加两行 |
| 原生崩溃 | Bugly 强项,自动符号化 | 支持 |
| 账号 / 采购 | **Bugly、神策都是现成的** | 要新开,自建还要内网资源和运维承接方 |
| 数据出境 | 无(都在境内) | SaaS 形态属数据出境,要走合规评估 |
| 门店网络可达性 | 无问题 | SaaS 形态丢报率无法预估 |
| 接入成本 | 高:`native_crash` + 三个 Dart 捕获入口 + 神策看板都要自己做 | 低 |
**决定性的是「现成」那一行。** 崩溃平台这类基础设施,"已经在用、有人维护、合规口径已经过"的价值,高于"SDK 接入省两天"。混淆那一条是可以用构建参数换掉的,采购周期和数据出境评估换不掉。
### `native_crash`Bugly 得自己包
Bugly 只有 Android / iOS 原生 SDK。pub.dev 上那两个社区插件(`flutter_bugly``bugly_pro_flutter`)都是 unverified,**不采用**——崩溃上报是出了问题最不该再出问题的一环,不押在一个可能停更的包上。
所以 `native_*` 里**要加一个 `native_crash` 包**(包清单见 [01-project-structure.md](./01-project-structure.md)Pigeon 约定见 [07-native-integration.md](./07-native-integration.md)),几十行 Kotlin/Swift
```dart
// packages/native_crash/pigeons/crash_api.dart
@HostApi()
abstract class NativeCrashApi {
void setUserId(String userId);
void putUserData(String key, String value); // storeId / roleCode / flavor 等维度
void postException(String type, String message, String stackTrace, Map<String, String> extra);
void log(int level, String tag, String message); // 进 Bugly 的崩溃附加日志
}
```
**`native_crash` 和其它 `native_*` 包有一条本质区别:它的初始化不在 Dart 侧。** Bugly SDK 必须在原生的 `Application.onCreate()` / `AppDelegate` 里尽早初始化,**启动期原生崩溃发生在 Flutter engine 起来之前**,等 Dart 调过来就已经漏了。因此:
- SDK 初始化写在原生侧,`appId` / `appKey` 按 flavor 从原生资源里取(Android 走 `src/{flavor}/`iOS 走 xcconfig,见 [08-build-flavors.md](./08-build-flavors.md)),**不经过 `--dart-define`**——Dart 侧拿到的时候太晚了。
- Pigeon 接口里因此**没有 `init()`**,只有初始化之后才用得上的那几个方法。
- 但**「用户同意隐私政策前不初始化」这条硬要求依然成立**:原生侧读一个本地标记位,没同意就不初始化 SDK。这个标记位由 Dart 侧在同意后写入(见「不采集什么」)。
### Dart 异常的三个捕获入口
没有 SDK 帮忙接管,三个入口要自己写全,**漏掉哪个就是那一类异常静默丢失**。完整的 `main()` 写法见 [12-error-and-api-contract.md](./12-error-and-api-contract.md) 第五节,这里只强调三者缺一不可:
| 入口 | 覆盖 |
|---|---|
| `FlutterError.onError` | widget 构建 / 布局 / 绘制期的异常 |
| `PlatformDispatcher.instance.onError` | 框架之外、engine 层冒上来的异步异常 |
| `runZonedGuarded` 的 onError | zone 内未捕获的异步异常(`Future` 里 throw 且没人 catch |
**这里和"用现成 SDK"是一个反转的风险**:接三方 SDK 时最常见的错误是"SDK 已经接管了还手写一遍,同一个异常报两次";自己接反过来变成**"以为有人管,结果三个入口都没写"**,表现是后台一片安静、看起来 App 特别稳定。这比重复上报危险得多,见下文「验证接入真的成功了」。
### `ErrorReporter`:业务代码只见这一个接口
```dart
// packages/core_logging/lib/src/error_reporter.dart
abstract interface class ErrorReporter {
void setUser(String userId);
void setTag(String key, String value);
void leaveBreadcrumb(String message);
void recordFlutterError(FlutterErrorDetails details);
void recordError(Object error, StackTrace? stack, {bool fatal = false, Map<String, String> context = const {}});
}
```
`feature_*` 不直接 import `native_crash`,也不直接 import 神策。这一层的价值有三个:测试里能 mock 掉、`feature_*` 少一条对三方 SDK 的直接依赖(见 [01-project-structure.md](./01-project-structure.md) 的依赖规则),以及**"一条异常同时进 Bugly 和神策"这个双写逻辑只写在一个地方**。
默认实现做的事:
```dart
void recordError(Object error, StackTrace? stack, {bool fatal = false, Map<String, String> context = const {}}) {
final type = error.runtimeType.toString();
final scrubbed = scrub(stack.toString()); // 见「脱敏」
final extra = {...context, ...currentTags}; // storeId / roleCode / route / traceId / flavor
// ① Bugly:进崩溃率口径,堆栈按 SDK 的长度上限截断
_native.postException(type, scrub(error.toString()), truncate(scrubbed), extra);
// ② 神策:进错误看板,属性可查询、可分组
_analytics.track('app_error', {
'errorType': type,
'errorMessage': scrub(error.toString()),
'stackTrace': truncate(scrubbed),
'fatal': fatal,
...extra,
});
}
```
**双写只在这个方法里发生,业务侧永远只调一次 `recordError`。**
### 两边报同一件事,怎么不打架
同一条 Dart 异常在 Bugly 和神策各有一份,两边数字对不上是必然的(采样、投递时机、去重规则都不同)。所以先把口径定死,**不要指望两个数字相等**:
| | Bugly | 神策 `app_error` |
|---|---|---|
| 看什么 | **崩溃率、稳定性趋势、版本对比** | **错误明细、按门店/角色/路由下钻、和业务事件关联** |
| 权威口径 | ✅ 对外汇报稳定性用这个 | 参考 |
| 强项 | 原生崩溃自动符号化、设备/系统分布 | 自定义属性可查询,能和 `api_failed``h5_failed` 放在同一套分析里 |
**"这个版本稳不稳"看 Bugly"这个错误是怎么来的"看神策。** 任何一份周报里不要把两个数字并排放,会引出解释不清的问题。
### 神策上的错误看板要自己搭
神策不是崩溃平台,`app_error` 对它来说就是一个普通事件。所以下面这些是**必须自己做的二次开发**,排期里要留出来:
- **事件属性先在神策后台建好**`errorType``errorMessage``stackTrace``fatal``route``traceId`)。属性没预先定义,上报上去会被丢弃或者变成不可分组的字符串。
- **`stackTrace` 必须截断**。神策的字符串属性有长度上限,超长属性会被截断甚至导致整条事件入库失败。约定**只取前若干帧**(建议 20 帧)而不是按字符数硬切——按字符切会把最关键的顶层帧留下、底层调用链切没,反而是切错了方向。具体上限值以所用神策版本的文档为准(见待确认项)。
- **看板**:错误数趋势、`errorType` TOP N、影响设备数、按 `appVersion` 对比、按 `storeId` 分布。
- **告警**:新版本发布后错误数突增、某个 `errorType` 首次出现。神策的预警功能够用,不需要另做。
**`stackTrace` 不做去重聚合是这套方案最大的短板。** 崩溃平台会自动把同一个错误的不同实例归并成一个 issue,神策没有这个能力——只能靠 `errorType` + 堆栈首帧拼一个 `errorGroup` 属性自己分组。**这个 `errorGroup` 要在客户端算好再上报**,放到神策里用公式算不出来。
### 用户与门店上下文
会话状态变化时同步(见 [11-store-context-and-session.md](./11-store-context-and-session.md)):
```dart
ErrorReporter.instance
..setUser(session.user.userId) // 只传 ID,不传手机号/姓名
..setTag('storeId', '${session.store.storeId}')
..setTag('roleCode', session.user.roleCode)
..setTag('flavor', env.flavorName);
```
内部同时落到两侧:`setUser` 走 Bugly 的 `setUserId` 和神策的 `login``setTag` 走 Bugly 的 `putUserData` 和神策的超级属性。**这也是「一次调用、两处生效」只写在 `ErrorReporter` 里的原因**——散到调用点去写,迟早有一侧漏掉。
**`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 ErrorReporter _reporter;
@override
void didPush(Route route, Route? previous) =>
_reporter.leaveBreadcrumb('nav: ${route.settings.name}');
}
```
**这里没有 SDK 自带的面包屑可用**,环形缓冲是我们自己的:Bugly 侧靠 `log()` 写进崩溃附加日志,神策侧作为 `app_error``breadcrumbs` 属性带上(同样要截断)。当前路由名另外单独作为 `route` 属性上报——它是错误看板里最常用的分组维度,埋在一段拼接文本里就没法分组了。
注意**记的是路由名不是完整 URL**——`/webview?target=X&ticket=...` 里带着票据(见脱敏一节)。同理再补三处业务关键节点:H5 启动/失败、门店切换、扫码。这三条链路最长、最容易出问题。
### 上报什么、不上报什么
- **上报**:未捕获的 Dart 异常、原生崩溃、ANR、Riverpod provider 抛出的异常(通过 `ErrorObserver`,即使 UI 已经优雅处理了——"用户看到了漂亮的错误页"和"不需要知道有多少人看到"是两回事,见 12)。
- **不上报**`RequestCancelledException`(用户正常退出页面)、`UnauthorizedException`(正常的登出流程)。这两类是业务流程的一部分,上报只会把真正的崩溃淹掉。
### 验证接入真的成功了
崩溃上报最常见的失败模式是**静默不上报**——数据没传上来,但你以为 App 很稳定。自己接三个捕获入口之后,这个风险比用现成 SDK 时更高,所以验证是硬要求:
- `core_logging` 暴露一个 `throwTestException()`**只在 dev flavor 下可调**,每次发版前在 dev 上验证一遍 Android 和 iOS。
- **三个入口要分别触发、分别验证**:build 期抛错(`FlutterError.onError`)、`Future` 里抛错不 catch`runZonedGuarded`)、原生侧主动 crash(Bugly)。只测一个入口是通不过的——漏写的那个恰恰是没被测到的那个。
- **每次验证都要确认两侧都收到了**:Bugly 后台有这条崩溃、神策里有对应的 `app_error` 事件。只对一侧就等于双写逻辑没验证。
- **验证堆栈是不是可读的**——这一步比"能收到"更容易漏。打一个 release 包(按上文的 `--split-debug-info` 且**不带** `--obfuscate`)触发异常,确认上报回来的是 `OrderRepository.submit` 而不是 `_x12`。看到 `_x12` 就说明构建参数被改回去了。
- uat 环境跑一个迭代后,对一下 Bugly 的错误数和神策里 `app_error` 的数量级,差得离谱说明有一侧漏了。
## 二、日志规范
### `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 退出即消失,避免日志文件成为新的泄漏面。实现上由 `ErrorReporter.recordError` 在上报时取出,Bugly 侧走 `log()` 写进崩溃附加日志、神策侧作为事件属性带上;两边都有长度上限,所以实际带的是**最近 30 条**,不是全部 500 条。
### 脱敏(PRD REQ-NFR-019 日志脱敏)
```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。
- 崩溃上报前再做一遍同样的脱敏——环形缓冲里的日志、异常 message 和堆栈都会随崩溃一起传上去。
**脱敏必须做在 `ErrorReporter` 的出口,不是各调用点。** 自己接上报有一个隐蔽的好处:没有任何 SDK 会背着我们自动记录 HTTP 面包屑,我们在 05/10 里保证的"URL 不落日志"不会被某个默认行为绕过去。但反过来,**凡是要送出去的东西都得自己过一遍脱敏器**,没有 SDK 的钩子兜底:
```dart
// packages/core_logging/lib/src/scrubber.dart
/// 异常 message 与堆栈里同样可能出现 URL、手机号、车牌
String scrub(String raw) => raw
.replaceAllMapped(_urlPattern, (m) => Uri.parse(m[0]!).replace(query: '').toString())
.replaceAllMapped(_phonePattern, (m) => maskPhone(m[0]!));
```
最容易漏的是**异常 message 本身**`DioException``toString()` 里带着完整 URL(含 `ticket`),原样上报等于把 token 发出去。`recordError` 里对 `error.toString()``stack.toString()` 都要跑一遍 `scrub`,见上文的实现。
### 不采集什么
出于合规(个人信息保护法「最小必要」原则)和 PRD REQ-NFR-019
| 项 | 结论 |
|---|---|
| 崩溃截图 / 页面录制 / 控件树 | **不采集**。收银、经营分析页面上有金额和客户信息 |
| 精确位置 | 不采集。App 没有需要精确位置的功能 |
| IMEI / IDFA / MAC / AndroidID | **不采集**(见 05`X-Device-Id` 用的是匿名安装 UUID)。**神策原生 SDK 默认会采集设备标识来生成 `distinct_id`,必须在初始化时逐项关掉**;**Bugly 默认也会采集设备信息,同样要逐项过一遍并按隐私政策裁剪** |
| 通讯录、短信 | 不申请权限 |
| 用户输入的原文 | 不打日志(包括搜索关键词里可能出现的车牌、手机号) |
这份清单要和 App 的隐私政策(PRD REQ-LGN-005,由 App Backend 下发)**逐条对齐**——隐私政策里没写的,代码里就不能采。**第三方 SDK 的默认采集行为是最容易在合规审查时出问题的地方**:神策和 Bugly 的隐私说明都要单独过一遍,并且要在**用户同意隐私政策之前不初始化**,否则「同意前不采集」这条硬要求就破了。
**Bugly 这一条要特别注意**:它在原生侧初始化(见上文 `native_crash`),"延迟到同意之后"不是 Dart 里加个 if 就行的——原生侧要读一个本地标记位来决定初不初始化,标记位由 Dart 在用户同意后写入并持久化。**这个标记位一旦漏写,表现是"合规过不了"而不是"崩溃收不到",测不出来。**
## 三、埋点:以后端为主
**大部分业务埋点由后端从自己的请求日志和审计日志里出,客户端不重复做一遍。**
理由很直接:任何一个业务动作(登录、切店、下单、入库、打开 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。哪一种更合适取决于后端的数仓现状,一并列进待确认项。
### 分工
| 埋点事件 | 谁来出 | 说明 |
|---|---|---|
| 登录成功/失败 | **后端** | 登录本身就是接口调用 |
| 首页曝光 | **后端** | 首页聚合接口的调用即曝光 |
| 门店切换 | **后端** | 切换接口 |
| 采购下单 | **后端** | |
| 入库成功 | **后端** | |
| 待办点击 | **后端** | 点击后会请求详情接口 |
| **扫码成功/失败** | **客户端** | 扫码是 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 里不多见。**团队过往项目用过,事件模型和数据接入的坑踩过一遍**,这是选它最实在的理由。
**注意本项目里神策承担了两件事**:客户端行为埋点(本节),以及上文的错误明细与看板(`app_error` 事件)。后者是二次开发出来的,不是神策的现成能力——**排期要按"两块工作"算**。
#### 神策不是"开箱即用",这些活一样要干
先把预期摆正,否则排期一定会低估。**接了神策之后,下面这些工作量和自建一套上报是完全一样的**:
- **事件方案设计**——事件名、属性、口径对齐。这才是埋点的大头,跟用什么 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(); // 登出时调
}
```
理由和 `ErrorReporter` 一样:测试里能 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) 的 13 项)里也没有埋点这一项。
### 命名约定
`snake_case``对象_动作``对象_动作_结果`。结果类用过去式(`succeeded`/`failed`),动作类用现在式(`clicked`)。
事件名和参数名一旦上线**不再改**——改名意味着历史数据断裂,运营报表要重做。要加维度就加参数。事件名统一定义为常量(`AnalyticsEvent.scanSucceeded`),不允许在调用处写字符串字面量:拼写错误编译期发现不了,在报表里表现为"这个事件怎么没数据"。
## 四、性能指标
| 指标 | 怎么测 | 目标 |
|---|---|---|
| 冷启动到首帧 | `WidgetsBinding.instance.addTimingsCallback` | < 2s |
| 冷启动到首页可用 | `main()` → 首页数据渲染完成 | < 3s |
| H5 打开耗时 | `h5_first_paint``ticketMs + loadMs` | < 3sPRD REQ-NFR-005 要求定义 H5 白屏超时阈值) |
| 接口耗时 | 后端侧统计即可,客户端不重复报 | P95 < 1s |
| 帧率 | 先不做自动采集,用 DevTools 人工测关键页面 | — |
**接口耗时不由客户端报**:后端有完整的请求日志和 Micrometer 指标(见 backend/08)。客户端唯一能补充的是"客户端观测到的耗时 - 服务端处理耗时 = 网络耗时",这个差值有价值但不是首版必须,先不做。
## 五、和后端审计日志的分工
`backend/08-observability.md` 已经有 `@Audited` 审计日志通道。**审计以服务端为准,客户端不做审计**——客户端日志可被篡改,不能作为审计依据。
| | 客户端埋点 | 服务端日志/审计 |
|---|---|---|
| 目的 | 补齐后端看不到的行为 | 业务分析、合规追溯 |
| 可信度 | 参考 | 权威 |
| 覆盖 | 纯客户端行为、请求失败 | 所有到达服务端的操作 |
## 待确认项
- **release 到底混不混淆**——本篇最需要拍板的一条。本文的决策是**不混淆**(只 `--split-debug-info`),换来 Dart 堆栈直接可读;要和安全侧确认能否接受 Dart 符号暴露。**若安全侧坚持混淆,就要接受每条 Dart 崩溃人工 `flutter symbolize` 的排查成本**[08-build-flavors.md](./08-build-flavors.md) 的构建参数需一并改回。
- **Bugly 的 appId / appKey 与项目划分**dev/uat/prod 是三个 Bugly 产品还是一个产品靠版本号区分(建议 prod 单独一个,避免测试崩溃污染线上崩溃率)。
- **神策的数据接收地址与项目划分**:私有化部署还是神策云,dev/uat/prod 怎么分。地址走 `--dart-define-from-file`,代码里不写死。
- **神策字符串属性的实际长度上限**(决定 `stackTrace` 截多少帧),以所用神策版本的官方文档为准,接入时实测确认。
- **`errorGroup` 的分组算法**`errorType` + 堆栈首帧够不够用,还是要按包名过滤掉框架帧再取第一条业务帧。这一条直接决定错误看板可不可用,建议接入后拿真实数据调一轮。
- 神策原生 SDK 与 Bugly SDK 的默认设备信息采集项(`distinct_id` 生成方式、是否取 AndroidID/IDFA),需逐项关闭并与隐私政策对齐(法务侧)。
- 两个 SDK 都要在**用户同意隐私政策之后**才初始化。Bugly 在原生侧初始化,需要一个由 Dart 写入、原生读取的持久化标记位,具体时机要和 `feature_auth` 的协议弹窗流程对齐。
- **客户端事件和后端业务数据怎么汇到一起**:是后端用神策服务端 SDK 写进同一个神策项目,还是把神策的客户端事件导出到后端数仓、统一用 Metabase 出图。取决于后端数仓现状和 Metabase 的实际使用情况,要和后端一起定。
- **后端的业务埋点写不写进同一个神策项目**(用神策服务端 SDK / 数据导入),还是留在自己的 ELK 里出报表。影响的是能不能做跨端漏斗分析。
- 后端从请求日志出业务埋点的具体口径(哪个接口对应哪个事件),需要和后端一起把埋点事件逐条落到接口上。
- 性能指标目标值需在真机(门店常用的中低端 Android)实测后校准,上表是初始预期值。
## 参考链接
- [腾讯 Bugly](https://bugly.qq.com/)(崩溃上报,原生侧)
- [Bugly Android SDK 接入文档](https://bugly.qq.com/docs/user-guide/instruction-manual-android/)
- [Bugly iOS SDK 接入文档](https://bugly.qq.com/docs/user-guide/instruction-manual-ios/)
- [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)
- [Flutter: 错误处理(`FlutterError.onError` / `PlatformDispatcher.onError`](https://docs.flutter.dev/testing/errors)
- [logger | Dart package](https://pub.dev/packages/logger)
- [backend/08-observability.mdtraceId 与审计日志](../backend/08-observability.md)
- [PRD 第 6.8 节 埋点 / 第 8.3 节 可用性与容错](../prd/Continental-Retail-APP-PRD.md)