30 KiB
13. 可观测性与埋点
为什么单独一篇
PRD §21.4 和 §22.1 有明确要求(主链路 Trace ID、H5 打开/关闭/失败事件、关键业务审计日志、9 类埋点事件),但 01-12 里完全没有落点。同时,App 侧的可观测性是排查线上问题唯一的手段——后端有 ELK 可以查日志,App 装在几百家门店的员工手机上,没有上报就等于全盲。
这一篇定三件事:崩溃上报、日志规范、埋点规范。
决策
| 项 | 决策 |
|---|---|
| 崩溃上报 | sentry_flutter ^9.26.0(官方 verified publisher),配套 sentry_dart_plugin ^3.4.0 上传符号表 |
| Sentry 部署形态 | 自建优先(sentry.io SaaS 是跨境上报),待确认 |
| 本地日志 | logger ^2.7.0,封装在 core_logging 的 AppLogger 后面 |
| 客户端埋点 | 神策 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 要求 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 里的 Pigeon 包只剩 native_scan / native_media。
唯一还没定的:自建还是 SaaS
这一条必须在开工前定,它决定的不只是可达性,还有合规:
- 自建(推荐):崩溃数据不出境,门店网络下上报可靠,长期成本可控。代价是要一套内网 K8s/VM 资源和运维承接方。
sentry.ioSaaS:零运维,但崩溃报告里带着userId、storeId、面包屑和日志片段,属于数据出境,要走合规评估;同时门店网络访问境外服务的丢报率无法预估。
需要在讨论时明确的:有没有可用的内网资源、谁运维、以及法务对崩溃数据出境的口径。在结论出来之前,SENTRY_DSN 走 --dart-define-from-file(见 08),代码里不写死任何地址——换 DSN 不需要改一行代码。
依赖与初始化
dependencies:
sentry_flutter: ^9.26.0
dev_dependencies:
sentry_dart_plugin: ^3.4.0
// 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:
// 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 的依赖规则)。
符号表:唯一必须打通的一步
sentry_dart_plugin 包装 sentry-cli,构建后一条命令把 Dart 符号表、Android mapping、iOS dSYM 一起传上去:
# pubspec.yaml
sentry:
upload_debug_symbols: true
upload_source_maps: false # 不做 Web
project: conti-retail-app
org: continental
# auth_token 走 CI 环境变量 SENTRY_AUTH_TOKEN,不写进仓库
# 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):
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)。
崩溃前的页面路径
崩溃报告里最有用的上下文之一是"崩之前用户在哪几个页面"。go_router 的 observers 挂一个 NavigationObserver(见 04-routing.md),把最近的路由变化写进环形缓冲,随崩溃一起上报:
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
// 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「敏感字段脱敏」)
// 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 的实现:
// 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)。客户端再报一遍,得到的是同一件事的两份数据——而且客户端那份还更不可靠(可能丢、可能延迟、可能被篡改)。
这两份数据最好落到同一个地方。 神策有服务端 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 |
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 的级联清单)。
- 私有化部署的运维(如果走私有化)。
神策真正替我们省掉的只有一件具体的事:客户端的可靠投递。 原生 SDK 自带本地缓存、批量上报、弱网重传、后台 flush 和进程被杀后的补发。门店网络不稳,这个模块不能省,自己写的话是容易写得看起来对、实际在丢数据的那一类——丢了还不会有人发现。这一条就是选现成 SDK 的全部收益,其余都要照做。
依赖与封装
dependencies:
sensors_analytics_flutter_plugin: ^4.2.3
业务代码仍然只见 core_analytics 的接口,不直接 import 神策:
// 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 的级联清单)。 - 登录/登出走
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 的 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
- sentry_dart_plugin | Dart package(上传 Dart 符号表 / mapping / dSYM)
- Sentry: Flutter Debug Symbols
- Sentry: 自建(self-hosted)
- sensors_analytics_flutter_plugin | Dart package
- 神策:Flutter 插件集成文档
- 神策:Flutter 全埋点
- Metabase(BI 层,非采集方案;官网把 Mixpanel/PostHog/Amplitude 列为上游采集集成)
- Flutter: 混淆与
flutter symbolize - logger | Dart package
- backend/08-observability.md:traceId 与审计日志
- PRD §21.4 可观测性 / §22.1 埋点