用户说 App 卡,但说不清在哪?我把 Flutter 监控 SDK 升级成了链路观测工作台
大家好,去年我写过一篇 Flutter 全链路监控 SDK 那一版可以理解成 v1。
v1 的目标很直接:Flutter App 里发生了什么问题,我要尽可能采到。
比如:
- Flutter framework error;
- Dart uncaught error;
- 启动耗时;
- 页面加载耗时;
- Dio / http 请求耗时和状态码;
- 页面 PV 和停留时长;
- 用户关键点击;
- Flutter frame timing 卡顿序列;
- app、user、device、platform、timestamp 等上下文。
这些能力都很有价值。
但继续往下做,我发现只"采到"还不够。
因为真实排查问题的时候,开发者面对的往往不是一个干净的指标名,而是一句非常朴素、非常折磨人的反馈:
"刚才 App 卡了一下。"
或者:
"订单页好像很慢,但我也说不清是哪一步。"
又或者更真实一点:
"用户说支付那里转圈了很久,后来又好了。"
这时候,如果监控系统只告诉你:
- 有一条卡顿事件;
- 有一个接口 800ms;
- 有一个页面加载 1200ms;
- 有一个错误堆栈;
它当然有价值,但还不够。
真正能帮助定位的问题是:
- 这个问题发生在哪个用户的哪次 session?
- 当时在哪个页面、哪个模块、哪个 route stack?
- 卡顿前用户点了什么?
- 同一时间有没有接口请求?
- 这个接口是不是影响了页面首帧或可交互?
- 当时设备、网络、版本、灰度开关是什么?
- 后面有没有跟着错误、重试、内存压力或生命周期切换?
所以这次 v2,我做的不是"再加几个指标",而是把 Flutter Monitor 从一个指标采集 SDK,升级成了一个 以链路为组织方式的端侧观测 workspace。
一句话总结:
v1 解决"我能不能采到问题";v2 解决"我能不能还原问题发生前后到底经历了什么"。
为什么指标不等于现场
做监控很容易有一个错觉:指标越多,排查越容易。
启动慢,就加启动耗时。
页面慢,就加页面耗时。
接口慢,就加 HTTP 耗时。
卡顿,就加慢帧统计。
崩溃,就加错误堆栈。
这些都对,但它们只是"点"。
真实问题往往是"线"。
比如用户反馈一个详情页卡住,背后可能是这样的:
text
进入首页
-> 点击 banner
-> 请求活动接口
-> 跳转商品详情
-> 商品详情接口慢
-> 列表构建卡顿
-> 触发一个空对象错误
如果这些事件散落在日志里,开发者要靠人肉把它们拼起来。
这件事非常累。
更麻烦的是,很多时候用户和 QA 说不清路径。
他不会告诉你 traceId,也不会告诉你 route stack。他只会说:
"刚才那个页面卡了一下。"
所以我希望监控 SDK 不只是采到一堆事件,而是能把这些事件组织成一次可回放的用户会话。
v2 的核心不是推翻 v1,而是把 v1 已经采到的信号重新组织起来。
v1 到 v2:从采集器升级成链路观测
v1 更像一个"多信号采集器"。
每个模块都很清楚:
ErrorMonitor负责错误;PerformanceMonitor负责启动和页面;- 网络 interceptor 负责 HTTP;
JankMonitor负责卡顿;- 行为模块负责点击和 PV;
Reporter负责分发。
这套设计适合早期阶段。
因为早期最重要的是先有信号。
但当信号越来越多以后,我遇到了几个问题。
第一,事件之间的关系不稳定。
一条 HTTP 事件、一条 page load、一条 jank sequence、一条 error 可能发生在同一个页面附近,但它们之间不一定有统一的 session、trace、span 关系。
第二,上下文容易漂移。
事件发生时在 /checkout,但异步 flush 时用户已经跳到 /profile。如果到 flush 时才读当前 route,就会把事件挂到错误页面。
第三,展示层容易补第二套模型。
如果 Workbench 为了展示方便,自己重新解释字段、自己聚合状态、自己补 route,那它很快就会变成另一个事实源。
第四,服务端协议容易发散。
如果 SDK 上报一套结构,DevTools 导出一套结构,Workbench 查询又是一套结构,后续任何字段演进都会很痛苦。
所以 v2 的方向很明确:
text
保留原有信号源
-> 补 session / trace / span / breadcrumb / context
-> 进入统一 EventEnvelope
-> 统一输出到日志、Workbench、服务端和未来工具
v2 的核心模型:session / trace / span / breadcrumb
先把几个概念说清楚。
session 是一次用户使用过程,或者一段可以分析的 App 活动窗口。
比如用户打开 App,从登录、进入首页、浏览详情、点击结算,到退出或切后台,这可以是一段 session。
trace 是一次可追踪流程。
比如冷启动、热启动、页面打开、一次关键业务操作、一次自定义流程,都可以是 trace。
span 是 trace 里的一个阶段。
比如 SDK 初始化、路由切换、页面首帧、HTTP 请求、图片解码、列表构建。
breadcrumb 是问题发生前后的关键足迹。
比如页面进入、点击、请求、弹窗、生命周期变化、卡顿、内存压力和错误。
context 是事件发生时的动态上下文。
比如用户、页面、模块、网络、生命周期、发布信息、灰度开关。
resource 是稳定资源。
比如 App 版本、设备、系统、SDK 版本、运行环境。
这些概念组合起来,目标是让一次用户会话可以被还原成类似这样的时间线:
text
session_abc
app.cold_start
page.view /home
ui.click home_banner
http.client GET /campaign
page.visit /product/detail
page.load /product/detail
ui.jank.sequence /product/detail
error NoSuchMethodError
page.stay /product/detail
这样看问题的方式就变了。
我不再只看"某个指标坏了",而是能看"这次用户使用过程发生了什么"。
先看 v2 成果:它已经不是单包 SDK
为了避免这篇文章变成纯概念,我先把 v2 现在的形态摆出来。
它已经不是一个单包 Flutter SDK,而是一个 workspace:
text
flutter_monitor/
docs/ 项目级模型、架构、采集、协议和计划
packages/
flutter_monitor_core/ 字段、schema、状态、summary、隐私规则
flutter_monitor_sdk/ Flutter runtime SDK、采集器、pipeline、outputs
flutter_monitor_native/ 可选 native 生命周期和内存增强
platform/
services/monitor-service/ NestJS:ingest、SQLite、查询、SSE、Swagger
web/ Workbench UI
shared/ TypeScript wire mirror 和共享 helper
核心职责可以这样理解:
| 模块 | 负责什么 |
|---|---|
flutter_monitor_core |
唯一事件模型、schema、字段注册、隐私规则、summary、export |
flutter_monitor_sdk |
Flutter runtime 采集、上下文、链路、pipeline、outputs、业务 API |
flutter_monitor_native |
可选 native memory、memory pressure、native lifecycle 等增强信号 |
Monitor Service |
接收统一事件、存储、查询、SSE、Swagger |
Workbench Web |
以 session 为主线做诊断展示 |
这次拆包的关键不是为了"看起来架构很高级",而是为了避免协议分裂。
如果 SDK、native、Workbench、服务端各自定义一套事件结构,后面一定会失控。
所以 v2 里我把 flutter_monitor_core 放在最底层:所有入口都复用同一套模型,不允许再出现第二套事件协议。
Example App:用真实场景验证链路
v2 里 example 不再只是一个简单 demo。
它模拟了一个更接近真实业务的 Flutter App:
text
Splash → Login(输入 userId 或随机生成)→ 首页 Tab App
登录成功后会写入 context.user.userId,这样 Workbench 可以按用户维度查 session。
页面结构包括:
- 启动页;
- 登录页;
- 首页;
- 我的事件 / 全部事件;
- 订单结算;
- 数据同步中心;
- 视频频道;
- 内容创作中心;
- 事件详情页。
里面故意设计了一些监控场景:
- Dio/http 成功请求;
- 404;
- timeout;
- 本地慢请求;
- 校验失败;
- 重试成功;
- 图表交互;
- 故意卡顿;
- retained memory;
- 手动错误;
- 业务 track;
- 交互 measure。
这些不是为了把 example 做得花哨,而是为了验证 v2 的目标:
- 页面事件能不能闭合;
- HTTP 能不能关联页面;
- track 能不能进入 breadcrumb;
- measure 能不能观察交互性能;
- jank 能不能关联当前 route;
- error 能不能携带 recent breadcrumbs;
- Workbench 能不能按 eventId、sessionId、traceId 查回完整事件。
这类示例对监控 SDK 很重要。
因为监控工具最怕"单点 demo 能跑,真实路径一接就乱"。
统一事件信封:所有信号都进 EventEnvelope
v2 里最关键的设计是 EventEnvelope。
不用展开所有字段,可以把它理解成五块:
| 区域 | 作用 |
|---|---|
| 公共字段 | 事件 ID、时间、类型、名称、状态、优先级、session/trace/span |
| resource | 稳定资源,比如 SDK、App、设备、运行环境 |
| context | 事件发生时的上下文,比如用户、页面、网络、生命周期、发布信息 |
| attributes | 可检索、可聚合的结构化字段,比如 HTTP 状态码、页面耗时、卡顿帧数 |
| payload | 事件详情,比如错误堆栈、breadcrumbs、HTTP 详情 |
这里最重要的是职责分层。
resource 放稳定信息。
比如 App 版本、设备型号、SDK 版本。
context 放事件发生时的动态环境。
比如当前用户、当前页面、当前网络状态、当前生命周期状态。
attributes 放可查询、可聚合、低基数的字段。
比如 http.status_code、page.load_ms、frame.slow_count。
payload 放事件特有详情。
比如错误堆栈、HTTP body 详情、breadcrumbs、诊断上下文。
这样做的好处是,服务端和 Workbench 可以稳定查询 attributes,但不会把巨大 payload 当成索引字段;排查时又可以回到 payload 看细节。
这套结构的价值是:所有信号都能被同一套工具消费。
错误不是一套格式,HTTP 不是一套格式,卡顿不是一套格式,native 也不是另一套格式。
它们都变成统一 envelope。
后续 Workbench、Service、DevTools、导出文件、未来 CLI/MCP 都只需要理解这一种结构。
SDK 运行时管线:采集器只采事实
v1 里很容易出现一种倾向:哪个模块采到事件,哪个模块就顺手把它格式化、上报、打印。
短期很快,长期会乱。
v2 里我把采集器的职责收窄:
采集器只负责捕获事实,不直接构建最终协议,不直接上报,不做采样、重试、隐私过滤和服务端适配。
最终统一进入 pipeline。
这个顺序很重要。
事件发生时先取上下文快照,避免异步 flush 时上下文漂移。
举个例子,用户在 /checkout 页面触发了一个失败请求,但等 SDK 真的 flush 时,用户可能已经跳到 /profile。如果那时才读取当前 route,这个失败请求就会被挂到错误页面。
所以 v2 要在事件捕获时固化 context。
然后补 session、trace、span 和相关 breadcrumbs。
再构建 EventEnvelope。
然后做 schema 校验、隐私过滤、采样限流。
最后才分发到不同 output。
这样一来,不管信号来自页面、HTTP、错误、卡顿、内存还是 native,最终都会走同一条路。
业务接入面反而变少了
v2 还有一个变化:公开 API 没有变得更复杂,反而更收敛。
普通业务不需要理解 RawSignal、FieldPaths、EventEnvelope、trace/span 内部细节。
业务侧常用入口主要是:
text
FlutterMonitorSDK.init(...)
FlutterMonitorSDK.setContext(...)
FlutterMonitorSDK.track(...)
FlutterMonitorSDK.measure(...)
FlutterMonitorSDK.recordError(...)
FlutterMonitorSDK.createDioInterceptor()
FlutterMonitorSDK.createHttpClient()
FlutterMonitorSDK.routeObserver
FlutterMonitorSDK.flush()
这些 API 背后的职责是:
| API | 作用 |
|---|---|
init |
初始化 SDK、配置输出模式和基础上下文 |
setContext |
设置用户、模块、发布、网络等通用排查上下文 |
track |
记录稳定低基数的业务动作,并进入 breadcrumb |
measure |
观测一次关键交互性能窗口 |
recordError |
记录业务 catch 住但仍需进入链路的错误 |
createDioInterceptor / createHttpClient |
自动采集网络请求并关联当前链路 |
routeObserver |
自动采集页面进入、停留、加载和 route context |
flush |
主动 flush output 缓存事件 |
这里的设计原则是:内部越复杂,外部越要克制。
业务侧只应该关心几件事:
- 初始化 SDK;
- 设置用户、模块、发布、网络等通用上下文;
- 记录关键业务动作;
- 观测一次关键交互性能;
- 手动记录业务 catch 住的错误;
- 接入路由和 HTTP;
- 必要时 flush。
至于事件怎么关联 session、怎么挂 breadcrumbs、怎么构建 envelope、怎么脱敏、怎么进队列,不应该暴露给普通业务代码。
三种输出模式:开发、QA、生产分开
v2 里我把输出模式收敛成三种:
| 模式 | 适合场景 | 行为 |
|---|---|---|
consoleOnly |
本地开发 | 只输出 compact log,不启用生产队列 |
localLive |
QA / 本地复现 | 小 batch 写入本地 Workbench service,便于近实时查看 session timeline |
production |
灰度或线上 | 启用离线队列、batch、重试、采样限流、优先级和 SDK 自监控 |
这个设计是为了避免业务方手动组合一堆 output、queue、retry、sampling 配置。
本地开发时,我只需要看到摘要日志,不想被完整 JSON 淹没。
QA 复现时,我希望事件尽快进 Workbench,方便刚复现完就看 session timeline。
生产环境里,我更关心可靠性和成本:
- 网络断了怎么办?
- 服务端暂时不可用怎么办?
- 队列满了怎么办?
- 某个事件太大怎么办?
- 采样丢了多少怎么办?
- flush 失败怎么办?
所以 production 模式需要离线队列、batch、重试、采样限流和 SDK 自监控。
也就是说,SDK 不只监控业务 App,也要监控自己有没有丢证据、有没有 flush 失败、有没有被限流。
真正上线后,脏活都在边界情况里
监控 SDK 在 demo 里很好写。
App 正常启动,网络正常,服务端正常,事件正常发送,Workbench 正常展示,看起来一切都很美。
但真实上线后,问题会变得很脏。
比如:
- 用户在弱网环境下连续操作;
- App 刚进入后台就被系统杀掉;
- 服务端短时间不可用;
- 某个页面短时间产生大量事件;
- HTTP body 或错误堆栈过大;
- 离线队列写满;
- 低端设备上 SDK 自己造成性能压力;
- 采样策略把排查关键证据采没了;
- flush 阻塞了业务线程;
- SDK 还没初始化完,业务错误已经发生;
- 用户登录态切换后,上下文不能污染旧事件。
这些才是生产 SDK 最麻烦的地方。
所以 v2 的设计里,我不只是关心"事件能不能发出去",还关心"发不出去的时候怎么处理,以及处理过程不能伤害宿主 App"。
我给 production 模式定了三条底线:
- 业务调用路径只做轻量采集,不等待网络;
- 所有事件先进入统一管线,完成 schema 校验、上下文快照和隐私过滤;
- 上报失败、队列拥塞、采样丢弃、flush 超时,都必须留下 SDK 自监控证据。
可以把生产链路理解成下面这张图:
这张图里最关键的不是 HTTP 上报,而是中间的"可靠投递层"。
因为真实 App 里,上报通道一定会遇到阻塞、失败、重试、限流和丢弃。SDK 的职责不是假装这些问题不存在,而是把它们设计成有边界、可降级、可审计的系统行为。
1. 弱网和服务端不可用:先入队,再批量发送
真实 App 不应该默认一条事件一个请求。
一事件一请求在本地 demo 里很直观,但线上会放大网络成本,也更容易在弱网下失败。
所以 production 模式应该走:
text
EventEnvelope
-> 离线队列
-> batch
-> HTTP 上报
-> 失败后重试
-> 成功后确认移除
发送失败时,不应该立刻丢事件。
网络超时、5xx、429、临时服务不可用,这些都属于可重试场景。SDK 应该把事件保留在队列里,按指数退避或固定策略重试。
但也不能无限重试。
无限重试会拖垮设备、电量和网络,也会让队列永远清不掉。
所以需要有:
- 最大重试次数;
- 重试退避;
- 单批最大事件数;
- 单批最大字节数;
- 单事件最大字节数;
- 队列最大事件数;
- 队列最大字节数;
- 事件最大保留时间。
这些参数看起来不性感,但它们决定了 SDK 能不能真的上生产。
还有一个容易被忽略的细节:删除队列事件应该基于确认,而不是基于"请求已经发出"。
也就是说,事件进入 batch 之后,不代表它已经安全离开本地。只有服务端确认接收后,SDK 才能把这批事件从离线队列里移除。
如果是网络超时、5xx、429 这类临时失败,就进入下一轮重试。
如果是 schema 不兼容、鉴权失败、服务端明确拒绝这类不可重试错误,就不能无限重试,而是要进入 non_retryable_rejected 或类似原因的 SDK health 审计。
2. App 被杀:flush 是尽力语义,不能假装百分百可靠
移动端最现实的情况是:App 不一定有机会优雅退出。
用户可能直接划掉进程。
系统可能因为内存压力杀掉 App。
后台时间可能非常短。
所以 flush on background 只能是尽力语义。
进入后台或退出前,SDK 可以更积极地 flush,但不能把它说成"保证所有事件都发出去了"。
更合理的设计是:
- 关键事件尽量提前入队;
- 后台时触发一次快速 flush;
- flush 有超时,不无限阻塞;
- 来不及发送的事件保留在离线队列;
- 下次启动后继续补发;
- 如果因为物理限制丢了事件,要记录 SDK health。
这里我很在意一个表达:不要假装可靠性是绝对的。
监控系统最怕静默丢证据。
丢了不可怕,可怕的是使用方不知道丢了多少、为什么丢。
3. 上传通道阻塞:flush 可以慢,业务线程不能等
弱网和服务端不可用解决的是"发不出去怎么办"。
但还有一个更隐蔽的问题:上报通道阻塞时,不能把采集路径一起拖住。
比如:
- 上一批还在 retry;
- 服务端响应很慢;
- 当前 flush 正在发送大 batch;
- App 进入后台,只剩很短的执行窗口;
- QA 复现时短时间产生大量事件;
- 业务手动调用了
flush,同时页面还在继续产生事件。
如果采集、入队、flush、HTTP 发送共用一把大锁,就很容易出现一种很糟糕的结果:监控 SDK 没有把问题发出去,反而把用户当前操作卡住了。
所以这里我更倾向把链路拆开:
text
业务调用
-> 快速生成事件
-> 异步进入队列
-> flush 任务按策略读取队列
-> batch 发送
-> ack 后删除 / 失败后更新重试计划
这里有几个设计点:
- 业务调用只负责把事实交给 SDK,不等待 HTTP;
- 入队和发送解耦,发送慢不会反向阻塞新的采集;
- 正在 flush 时,新事件可以继续进入队列,下一轮再发;
- app exit flush 使用短超时,不能拖慢退出;
- 同一时间避免多个 output 各自监听生命周期、重复 flush;
- retry 应该有
nextAttemptAt之类的调度概念,而不是失败后立刻死循环; - flush 成败、耗时、batch 大小、队列水位都要进入 SDK health。
这样设计之后,flush 的语义就更清楚了。
它不是"所有数据一定立刻上报成功",而是"尽量把当前可发送的数据推出去,并把失败原因留下来"。
4. 队列背压:不能让监控把 App 拖慢
监控 SDK 是寄生在业务 App 里的。
它再重要,也不能比业务本身更重要。
如果某个页面短时间产生大量事件,SDK 不能因为写队列、序列化 JSON、压缩 payload 或等待网络,把主线程卡住。
所以队列设计要有背压。
我希望它遵守几个原则:
- 采集路径尽量轻;
- 复杂处理放到 pipeline/output,避免阻塞业务调用;
- 队列有上限,不能无限增长;
- 队列满时按保留等级和优先级降级;
- 低价值事件先被采样、限流或驱逐;
- 高价值事件尽量保留;
- 所有丢弃都进入 SDK health 统计。
这也是为什么 v2 里要区分 hard、compressible、sampleable。
队列背压不是简单地"满了就丢最旧"。
更合理的是:先保住排查最关键的事实。
上报阻塞解决的是"发送慢时不要卡住 App"。
队列背压解决的是"真的堆不下时应该保谁、丢谁、怎么说明"。
如果没有背压,离线队列就可能无限膨胀,最后占用存储、拖慢查询、甚至影响业务 App 本身。
如果背压太粗暴,关键错误、卡顿、启动慢、内存压力这些排查证据又可能被普通日志挤掉。
所以 v2 的降级顺序大致是:
- 高频辅助事件先采样或限流;
- 可压缩事件先裁剪详情,只保留事实层;
- 队列压力继续升高时,按保留等级和优先级驱逐;
- 实在触达物理极限时,允许丢硬证据,但必须审计。
这里最重要的不是"永不丢",而是"丢得有规则,并且能被看见"。
5. 大事件:先裁剪详情,不要直接丢事实
真实事件可能很大。
比如:
- HTTP response body 很大;
- 错误堆栈很长;
- payload 带了大量诊断信息;
- breadcrumbs 太多;
- 某个业务 properties 传得过大。
如果一个事件超过上限,不能第一反应就整条丢掉。
更合理的是分层处理。
attributes 里的事实层尽量保留,因为它服务查询和聚合。
payload 里的详情层可以裁剪,比如:
- 截断 body;
- 去掉 query/detail;
- 裁剪 breadcrumbs;
- 保留 hash、长度和截断标记;
- 删除过大的非关键字段。
只有裁剪后仍然超过限制,才进入丢弃流程,并记录 payload_too_large 一类的 SDK health 证据。
这就是 EventEnvelope 分层的价值:事实和详情分开,才有空间做降级。
6. 隐私过滤:必须早于任何输出
监控 SDK 很容易碰到敏感数据。
URL query、request body、response body、token、cookie、手机号、身份证、地址、精确位置,都不能随便进日志或上报。
所以隐私过滤不能只放在 HTTP output 之前。
它必须早于任何 output:
text
EventEnvelope
-> schema 校验
-> 隐私过滤
-> log / HTTP / Workbench / file
否则就会出现一个很危险的情况:HTTP 上报脱敏了,但本地 log 或 Workbench 已经泄露了原始数据。
v2 的原则是:所有 output 只能消费脱敏后的统一 envelope。
7. SDK 自身性能:采集不能变成新的卡顿来源
监控 SDK 的悖论是:它要发现性能问题,但它自己不能制造性能问题。
所以我在设计时会尽量避免几个坑:
- 不在业务调用路径做重型 JSON 处理;
- 不在主线程等待网络;
- 不默认输出完整 JSON 刷屏;
- memory sample 默认低频;
- breadcrumb store 有数量上限;
- payload 有大小上限;
- batch 发送,不做高频小请求;
- compact log 只输出摘要;
- Workbench live 模式和 production 模式分开。
这里没有银弹。
监控 SDK 一定会有成本,关键是成本要可控、可配置、可降级。
我会把每一次 SDK 调用都当成"在用户交互路径上抢时间片"。
所以 public API 的设计不能只看好不好用,还要看它在最坏情况下会不会拖慢业务。
比如 track、recordError、HTTP interceptor、route observer 这类入口,应该尽量快速返回;真正重的工作放到 pipeline、队列和 output 后面去做。
8. 初始化前和上下文切换:缺失也要显式表达
还有一些边界非常容易被忽略。
比如 SDK 还没初始化完,Flutter error 就发生了。
或者用户刚登录,userId 才被设置。
又或者用户退出登录,后续事件不能继续带旧用户上下文。
这类问题不能靠"希望业务按顺序调用"解决。
v2 的做法是:
- 事件捕获时取 context snapshot;
- 旧事件不被新上下文改写;
- 缺少上下文时显式标记
context.missing和原因; - 用户登录、登出、切换账号时更新后续上下文;
- route 不可用、native bridge 不可用、平台能力受限,都要有明确缺失原因。
这样排查时至少知道:这个字段为什么没有。
不知道原因的空值,才是最难排查的。
把这些边界收敛一下,大概是这张表:
| 生产边界 | v2 的设计重点 | 不能做什么 |
|---|---|---|
| 弱网 / 服务端不可用 | 离线队列、batch、重试、ack 后删除 | 一条事件一个请求,失败就丢 |
| App 被杀 / 后台时间短 | 关键事件提前入队,后台尽力 flush,下次启动补发 | 承诺退出前一定全部发送成功 |
| 上传阻塞 | 采集、入队、发送解耦,flush 有短超时 | 让业务线程等待 HTTP |
| 队列写满 | 按保留等级、优先级、大小和 TTL 降级 | 无限增长,或无差别丢弃 |
| 事件过大 | 先裁剪详情层,保留事实层和截断标记 | 直接整条丢弃且不留原因 |
| 敏感数据 | 脱敏早于所有 output | 只在 HTTP 上报前脱敏 |
| SDK 性能成本 | 低频采样、大小上限、异步处理、compact log | 让监控制造新的卡顿 |
| 上下文缺失 / 切换 | 捕获时快照,缺失原因显式表达 | 用新上下文回填旧事件 |
hard 证据:不是所有事件都能随便采样
生产环境一定要考虑采样、限流、队列上限。
但不是所有事件都能一样处理。
v2 里我把事件保留策略分成几类:
| 类型 | 含义 |
|---|---|
| hard | 硬证据,问题定位的关键事实,不能被普通采样静默丢掉 |
| compressible | 有价值但可压缩,可以剥离详情或聚合 |
| sampleable | 高频辅助事件,可以采样或限流 |
比如:
- error;
http.client事实层;- 关键业务 action;
- interaction measure;
- 启动 end;
- jank sequence;
- memory pressure;
- SDK init / SDK health;
这些都是高价值证据。
它们不能像普通日志一样随便采样掉。
当然,这不代表 hard 事件"绝不丢失"。
如果离线队列达到物理极限,SDK 仍然可能不得不丢弃最旧事件。但关键是:不能静默丢。
必须进入 SDK health 审计,让使用方知道证据缺口的存在和规模。
这也是 v2 比 v1 更像生产系统的地方。
Workbench:不是 JSON Viewer,而是 Session 诊断工作台
v2 另一个很重要的成果是 Workbench。
Workbench 不是传统大盘,也不是普通 JSON Viewer。
它的定位是:
以 session 为主线的 Flutter 端侧链路诊断工作台。
它要帮助我回答:
- 刚才复现的 session 是哪一个?
- 这次 session 里发生了哪些页面、请求、点击、错误、卡顿?
- 某个错误前面发生了什么?
- 某个卡顿是否和 HTTP 请求重叠?
- 某个页面慢在页面加载、请求、卡顿还是错误?
- 我能不能从一个问题节点回到完整 envelope?
目前 platform 里已经有:
- NestJS Monitor Service;
- SQLite 本地存储;
/api/monitor/v1/eventsingest;- session / trace / event 查询;
- SSE 实时通知;
- Swagger API;
- React/Vite Workbench;
- Session Detail;
- Event Inspector;
- HTTP Detail;
- Performance Overview。
这也是 v2 和 v1 体感差异最大的地方。
v1 更像"我打印了很多监控日志"。
v2 更像"我能打开一个工作台,看这次复现到底发生了什么"。
一次典型排查应该怎么走
假设 QA 反馈:
用户 102 在结算页点击提交后卡了一下,后来提示失败。
我希望 v2 的排查路径是:
text
输入 userId + 时间范围
-> 找到对应 session
-> 进入 Session Detail
-> timeline 中看到 checkout.submit
-> 后面跟着 http.client timeout
-> 同一时间段出现 ui.jank.sequence
-> 最后出现 error 或 business failure
-> 点击任意节点查看完整 EventEnvelope
这里有几个关键点。
第一,开发者不需要先知道 sessionId。
真实反馈里很少有人能给出 sessionId。更自然的入口是 userId + 大概时间、App 版本、页面、错误、卡顿或慢请求。
第二,Session Detail 里先看 timeline。
不是先看 JSON,而是先看这次会话发生了什么。
第三,任何摘要都能回到原始 envelope。
UI 可以做时间线、状态标签、摘要卡片、HTTP Detail,但最终必须能回到完整事件。
第四,问题节点要能串上下文。
一个失败请求不只是状态码,它应该知道自己属于哪个页面、哪个 trace、前面有哪些 breadcrumbs、后面有没有错误或卡顿。
为什么 Workbench 不是传统监控大盘
传统监控大盘更关心聚合:
- 错误率;
- 慢请求率;
- P95;
- 影响用户数;
- 趋势;
- 告警。
这些当然重要。
但 Workbench 第一阶段更关心的是:
这一次发生了什么?
它服务的是本地调试、QA 复现和性能分析。
所以它的第一优先级不是做一个重型 BI 报表,而是让开发者从 session 出发,快速理解一次 App 使用过程。
长期趋势、告警、跨版本对比、影响用户数,可以放到后续服务端能力里。
Workbench 先做好 session timeline、节点诊断和 raw JSON 回查。
为什么我没有选择"无限加指标"
做监控 SDK 很容易陷入一个误区:不断加指标。
启动不够就加启动指标,页面不够就加页面指标,内存不够就加内存指标,native 不够就加 native 指标。
指标越来越多,但问题不一定更好查。
v2 里我更关心的是:这个指标能不能进入链路?
如果一个事件无法关联 session、页面、用户、版本、设备、网络、breadcrumbs,它就很难服务真实排查。
所以设计上我更偏向:
- 先稳定事件模型;
- 再接入信号;
- 再补生产可靠性;
- 最后做 Workbench 和服务端诊断。
这也是为什么文档里反复强调:
不要创建第二套事件模型。
和 v1 相比,v2 到底升级了什么
我用一张表简单总结:
| 维度 | v1 | v2 |
|---|---|---|
| 目标 | 采集错误、性能、网络、卡顿等指标 | 还原一次用户或 QA 会话链路 |
| 组织方式 | 独立事件 | session / trace / span / breadcrumb |
| 数据模型 | 偏模块化事件结构 | 统一 EventEnvelope |
| 上下文 | 事件里补充部分上下文 | 捕获时 ContextSnapshot 固化 |
| 输出 | 日志 / 简单输出 | consoleOnly / localLive / production |
| 可靠性 | 基础输出能力 | 队列、batch、重试、采样限流、SDK health |
| 展示 | 控制台和事件日志为主 | Workbench Session Timeline |
| 未来扩展 | 继续加模块 | core / sdk / native / platform 共享协议 |
这不是一次"功能堆叠",而是一次组织方式升级。
当前边界
这套 v2 不是说所有事情都已经完成。
当前主线更接近:
- Flutter-only SDK 主链路已经打通;
- core schema、pipeline、输出模式、Workbench 本地回查已经形成闭环;
- native 保留为可选增强方向;
- DevTools extension、CLI、MCP 等工具入口可以后置;
- 真实 App 灰度和长期服务端能力还需要继续验证。
这点我觉得要说清楚。
监控 SDK 这种东西,不能只看 demo 能不能跑。
它真正的挑战在灰度、弱网、离线、隐私、服务端兼容、长期字段演进和真实排查效率。
这次重构给我的几个启发
第一,监控不是采集越多越好,而是关联越清楚越好。
孤立指标适合看 dashboard,可关联的 session 和 trace 才适合调查真实问题。
第二,事件模型要先于功能扩展稳定。
如果字段和协议还没统一,就继续加采集器,后面一定会还债。
第三,业务 API 要克制。
监控 SDK 内部可以复杂,但普通业务接入不应该被迫理解 trace/span/envelope。
第四,Workbench 不应该成为第二套事实源。
UI 可以做摘要、筛选和图表,但事实仍然应该来自统一 envelope。
第五,生产可靠性不能后补。
离线、重试、采样、限流、队列上限、SDK 自监控,这些不是"上线后再说"的细节,而是监控系统本身可信的前提。
总结
Flutter Monitor v2 对我来说,不是一次"功能增加",而是一次组织方式变化。
从:
text
采集一堆指标
变成:
text
围绕 session 组织所有信号
从:
text
我知道 App 某处卡了
变成:
text
我能还原这次卡顿前后发生了什么
从:
text
控制台里刷一堆 JSON
变成:
text
Workbench 里看 session timeline 和问题节点
我越来越觉得,监控 SDK 的价值不是采到更多点,而是帮助开发者更快还原现场。
指标是基础,链路才是答案。