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

35 KiB
Raw Blame History

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 ^2.7.0,封装在 core_loggingAppLogger 后面
客户端埋点 神策 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 要求 ≥1 年)。关掉混淆后它不再是日常排查的必需品,但仍是拿精确行号的唯一手段,且丢了不可逆。

存档:为什么不另起一个崩溃平台

Bugly + 神策(采用) 独立崩溃平台(如 Sentry
Dart 堆栈还原 做不到,靠"不混淆"绕开 做得到(插件自动上传符号表)
Flutter 官方 SDK Bugly 没有pub.dev 上只有 unverified 社区插件,要自己写 native_crash 有,pubspec.yaml 加两行
原生崩溃 Bugly 强项,自动符号化 支持
账号 / 采购 Bugly、神策都是现成的 要新开,自建还要内网资源和运维承接方
数据出境 无(都在境内) SaaS 形态属数据出境,要走合规评估
门店网络可达性 无问题 SaaS 形态丢报率无法预估
接入成本 高:native_crash + 三个 Dart 捕获入口 + 神策看板都要自己做

决定性的是「现成」那一行。 崩溃平台这类基础设施,"已经在用、有人维护、合规口径已经过"的价值,高于"SDK 接入省两天"。混淆那一条是可以用构建参数换掉的,采购周期和数据出境评估换不掉。

native_crashBugly 得自己包

Bugly 只有 Android / iOS 原生 SDK。pub.dev 上那两个社区插件(flutter_buglybugly_pro_flutter)都是 unverified不采用——崩溃上报是出了问题最不该再出问题的一环,不押在一个可能停更的包上。

所以 native_*要加一个 native_crash(包清单见 01-project-structure.mdPigeon 约定见 07-native-integration.md),几十行 Kotlin/Swift

// 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),不经过 --dart-define——Dart 侧拿到的时候太晚了。
  • Pigeon 接口里因此没有 init(),只有初始化之后才用得上的那几个方法。
  • 但**「用户同意隐私政策前不初始化」这条硬要求依然成立**:原生侧读一个本地标记位,没同意就不初始化 SDK。这个标记位由 Dart 侧在同意后写入(见「不采集什么」)。

Dart 异常的三个捕获入口

没有 SDK 帮忙接管,三个入口要自己写全,漏掉哪个就是那一类异常静默丢失。完整的 main() 写法见 12-error-and-api-contract.md 第五节,这里只强调三者缺一不可:

入口 覆盖
FlutterError.onError widget 构建 / 布局 / 绘制期的异常
PlatformDispatcher.instance.onError 框架之外、engine 层冒上来的异步异常
runZonedGuarded 的 onError zone 内未捕获的异步异常(Future 里 throw 且没人 catch

这里和"用现成 SDK"是一个反转的风险:接三方 SDK 时最常见的错误是"SDK 已经接管了还手写一遍,同一个异常报两次";自己接反过来变成**"以为有人管,结果三个入口都没写"**,表现是后台一片安静、看起来 App 特别稳定。这比重复上报危险得多,见下文「验证接入真的成功了」。

ErrorReporter:业务代码只见这一个接口

// 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 的依赖规则),以及**"一条异常同时进 Bugly 和神策"这个双写逻辑只写在一个地方**。

默认实现做的事:

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_failedh5_failed 放在同一套分析里

"这个版本稳不稳"看 Bugly"这个错误是怎么来的"看神策。 任何一份周报里不要把两个数字并排放,会引出解释不清的问题。

神策上的错误看板要自己搭

神策不是崩溃平台,app_error 对它来说就是一个普通事件。所以下面这些是必须自己做的二次开发,排期里要留出来:

  • 事件属性先在神策后台建好errorTypeerrorMessagestackTracefatalroutetraceId)。属性没预先定义,上报上去会被丢弃或者变成不可分组的字符串。
  • stackTrace 必须截断。神策的字符串属性有长度上限,超长属性会被截断甚至导致整条事件入库失败。约定只取前若干帧(建议 20 帧)而不是按字符数硬切——按字符切会把最关键的顶层帧留下、底层调用链切没,反而是切错了方向。具体上限值以所用神策版本的文档为准(见待确认项)。
  • 看板:错误数趋势、errorType TOP N、影响设备数、按 appVersion 对比、按 storeId 分布。
  • 告警:新版本发布后错误数突增、某个 errorType 首次出现。神策的预警功能够用,不需要另做。

stackTrace 不做去重聚合是这套方案最大的短板。 崩溃平台会自动把同一个错误的不同实例归并成一个 issue,神策没有这个能力——只能靠 errorType + 堆栈首帧拼一个 errorGroup 属性自己分组。这个 errorGroup 要在客户端算好再上报,放到神策里用公式算不出来。

用户与门店上下文

会话状态变化时同步(见 11-store-context-and-session.md):

ErrorReporter.instance
  ..setUser(session.user.userId)        // 只传 ID,不传手机号/姓名
  ..setTag('storeId', '${session.store.storeId}')
  ..setTag('roleCode', session.user.roleCode)
  ..setTag('flavor', env.flavorName);

内部同时落到两侧:setUser 走 Bugly 的 setUserId 和神策的 loginsetTag 走 Bugly 的 putUserData 和神策的超级属性。这也是「一次调用、两处生效」只写在 ErrorReporter 里的原因——散到调用点去写,迟早有一侧漏掉。

storeId 一定要带。 它能直接回答"这个崩溃是不是只发生在某几家门店"——门店设备型号和网络环境高度集中,很多崩溃是设备相关的,没有这个维度只能盲猜。

traceId 在网络相关的错误上报时作为自定义属性带上,这样一条崩溃能直接关联到后端 ELK 里的那次请求(见 backend/08-observability.md)。

崩溃前的页面路径

崩溃报告里最有用的上下文之一是"崩之前用户在哪几个页面"。go_router 的 observers 挂一个 NavigationObserver(见 04-routing.md),把最近的路由变化写进环形缓冲,随崩溃一起上报:

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_errorbreadcrumbs 属性带上(同样要截断)。当前路由名另外单独作为 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 里抛错不 catchrunZonedGuarded)、原生侧主动 crash(Bugly)。只测一个入口是通不过的——漏写的那个恰恰是没被测到的那个。
  • 每次验证都要确认两侧都收到了:Bugly 后台有这条崩溃、神策里有对应的 app_error 事件。只对一侧就等于双写逻辑没验证。
  • 验证堆栈是不是可读的——这一步比"能收到"更容易漏。打一个 release 包(按上文的 --split-debug-info不带 --obfuscate)触发异常,确认上报回来的是 OrderRepository.submit 而不是 _x12。看到 _x12 就说明构建参数被改回去了。
  • uat 环境跑一个迭代后,对一下 Bugly 的错误数和神策里 app_error 的数量级,差得离谱说明有一侧漏了。

二、日志规范

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 退出即消失,避免日志文件成为新的泄漏面。实现上由 ErrorReporter.recordError 在上报时取出,Bugly 侧走 log() 写进崩溃附加日志、神策侧作为事件属性带上;两边都有长度上限,所以实际带的是最近 30 条,不是全部 500 条。

脱敏(PRD REQ-NFR-019 日志脱敏)

// 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、状态码、耗时、codetraceId。真要看 body 只在 dev 下开,且过一遍脱敏器。
  • Authorization 头永远不打,一个字符都不打——打前 8 位也不行,那既足够辅助暴力破解,也足够在日志里认出是谁的 token。
  • H5 URL 打日志前必须去掉 queryURL 里带着 ticket,整条打出去等于打 token。
  • 崩溃上报前再做一遍同样的脱敏——环形缓冲里的日志、异常 message 和堆栈都会随崩溃一起传上去。

脱敏必须做在 ErrorReporter 的出口,不是各调用点。 自己接上报有一个隐蔽的好处:没有任何 SDK 会背着我们自动记录 HTTP 面包屑,我们在 05/10 里保证的"URL 不落日志"不会被某个默认行为绕过去。但反过来,凡是要送出去的东西都得自己过一遍脱敏器,没有 SDK 的钩子兜底:

// 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 本身DioExceptiontoString() 里带着完整 URL(含 ticket),原样上报等于把 token 发出去。recordError 里对 error.toString()stack.toString() 都要跑一遍 scrub,见上文的实现。

不采集什么

出于合规(个人信息保护法「最小必要」原则)和 PRD REQ-NFR-019

结论
崩溃截图 / 页面录制 / 控件树 不采集。收银、经营分析页面上有金额和客户信息
精确位置 不采集。App 没有需要精确位置的功能
IMEI / IDFA / MAC / AndroidID 不采集(见 05X-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)。客户端再报一遍,得到的是同一件事的两份数据——而且客户端那份还更不可靠(可能丢、可能延迟、可能被篡改)。

这两份数据最好落到同一个地方。 神策有服务端 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)、durationMsfailReason
h5_closed H5 页关闭 targetstayDurationMs
h5_failed 白屏 / 超时 / 加载失败 targeterrorCodeelapsedMstraceId
h5_first_paint H5 首屏完成 targetticketMs(换票耗时)、loadMs(页面加载耗时)
support_clicked 客服入口点击 channel(hotline/dealer/o2o)
api_failed 请求失败 pathcodehttpStatustraceId
app_cold_start 冷启动完成 durationMs
logout 登出 reason(userInitiated / tokenExpired / sessionRevoked)。被动登出没有对应的接口调用,见 11
session_restore_failed 冷启动恢复会话失败 失败阶段(读 storage / me / stores)。卡在读 secure storage 时不产生任何网络请求

两条说明:

  • h5_first_paint 必须把耗时拆成 ticketMsloadMs 两段。合成一个数字的话,慢了不知道该找 App Backend / F6 / 还是网络——这是这个 App 里最长的一条跨系统链路,也是最容易互相甩锅的地方。
  • api_failed 客户端也要报,虽然后端也能看到失败。因为后端看不到"请求根本没发出去"和"响应没收到":超时、连接失败、DNS 失败、运营商劫持,这些在后端日志里要么完全没有记录,要么表现为一次正常的成功响应。门店网络不稳时这类失败占大头。

实现约定:神策 SDK,外面包一层

客户端埋点走神策 sensors_analytics_flutter_plugin:官方 verified publisher sensorsdata.cn4.2.3 一个多月前发布,是当前维护中的官方插件——这在 pub.dev 上的国内三方 SDK 里不多见。团队过往项目用过,事件模型和数据接入的坑踩过一遍,这是选它最实在的理由。

注意本项目里神策承担了两件事:客户端行为埋点(本节),以及上文的错误明细与看板(app_error 事件)。后者是二次开发出来的,不是神策的现成能力——排期要按"两块工作"算

神策不是"开箱即用",这些活一样要干

先把预期摆正,否则排期一定会低估。接了神策之后,下面这些工作量和自建一套上报是完全一样的

  • 事件方案设计——事件名、属性、口径对齐。这才是埋点的大头,跟用什么 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();                   // 登出时调
}

理由和 ErrorReporter 一样:测试里能 mockfeature_* 不多一条对三方 SDK 的直接依赖。另外它也是神策不可用时的缓冲——接口先定、事件方案先做,实现类换成一个最小的 POST /api/v1/events/batch 也只改一个文件(代价就是上面那条可靠投递要自己补,而且错误看板要跟着换地方)。

接入约定:

  • 公共属性用「超级属性」注册一次,不在每个调用点手写storeIdroleCodeflavorappVersionbuildNumberstoreId 尤其重要——运营侧几乎所有分析都按门店维度看,靠每个调用点自己传一定会漏。门店切换后必须重新注册(见 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 的 13 项)里也没有埋点这一项。

命名约定

snake_case对象_动作对象_动作_结果。结果类用过去式(succeeded/failed),动作类用现在式(clicked)。

事件名和参数名一旦上线不再改——改名意味着历史数据断裂,运营报表要重做。要加维度就加参数。事件名统一定义为常量(AnalyticsEvent.scanSucceeded),不允许在调用处写字符串字面量:拼写错误编译期发现不了,在报表里表现为"这个事件怎么没数据"。

四、性能指标

指标 怎么测 目标
冷启动到首帧 WidgetsBinding.instance.addTimingsCallback < 2s
冷启动到首页可用 main() → 首页数据渲染完成 < 3s
H5 打开耗时 h5_first_paintticketMs + 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 的构建参数需一并改回。
  • 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)实测后校准,上表是初始预期值。

参考链接