reliable_event_queue 埋点 设计思路与项目实践

5 阅读10分钟

前言:

  • 最近抽了一些下班后的时间,把“埋点上报”这块从零散业务代码中抽离出来,做成了一个可复用的 Flutter / Dart 插件:reliable_event_queue。一开始目标其实很简单,只是想解决“埋点别丢”这个问题。
  • 但真正落到项目后会发现,埋点上报并不是一个简单的 track -> request。弱网、前后台切换、上传失败、瞬时高频事件、批量重试、排障导出,这些问题只要项目稍微复杂一点,就一定会遇到。
  • 所以这个插件最终不只是“发事件”,而是补了一整套可靠队列能力:本地持久化、批量 flush、失败重试、死信队列、状态统计、导出调试,以及由业务侧自行接入网络和生命周期时机。
  • 本篇文章更偏实战一些,重点讲的是设计思路和落地方式,而不是单独讲 API。老规矩,先从整体思路开始。

正文:

当前项目中的 reliable_event_queue 使用,主要从下面几个维度来理解:

  1. 为什么要做可靠埋点队列插件

  2. 插件能力模型怎么设计

  3. Flutter 侧完整使用流程

  4. 队列、重试与持久化实现思路

  5. 如何在业务项目里落地

  6. 常见误区和实践建议

  • 为什么要做可靠埋点队列插件

在很多项目里,埋点通常是一句:

  • 点击按钮时直接调用一次接口

看起来简单,但真实业务里其实更复杂:

  • 用户点击后立刻切后台,请求还没发完

  • 弱网环境下接口超时,事件直接丢失

  • 音频上传、蓝牙同步、AI 对话这类链路事件很多,瞬时并发高

  • 某些错误类事件恰恰发生在网络最差的时候

  • 排查线上问题时,想看失败事件却没有任何本地留痕

如果只是页面里散着写 await track(),你能拿到的只是“尽量发一下”。

而可靠埋点队列插件的目标是:

  • 先把事件可靠地记下来

  • 再在合适时机批量上传

  • 失败后自动重试

  • 超过阈值的事件进入死信队列

  • 给业务和测试提供可观测、可导出的调试能力

也就是说:

  • 插件解决的是“可靠送达”

  • 业务层只关心“什么时候产生日志”

  • 插件能力模型设计

当前插件核心对象是 ReliableEventQueue,围绕它拆成了几类基础模型:

  • TrackEvent:业务侧最原始的埋点输入

  • QueuedEvent:进入队列后的标准事件对象,包含状态、重试次数、时间戳等

  • QueueConfig:队列行为配置

  • FlushResult:一次 flush 的执行结果

  • QueueStats:当前队列统计信息

  • QueueStatuspending / uploading / failed / success / deadLetter

  • EventUploader:由业务实现的上传器接口

对应能力分为几类:

  1. 初始化:initialize()

  2. 写入事件:track() / trackEvent()

  3. 手动上传:flush()

  4. 生命周期配合:onAppForeground() / onAppBackground() / updateNetworkState() / notifyUserInteraction()

  5. 查询与统计:getStats() / getPendingEvents() / getFailedEvents()

  6. 导出调试:exportAllEvents() / exportFailedEvents() / exportDeadLetterEvents()

  7. 状态监听:statsStream / debugStream

这种设计有个很大的好处:

  • 插件只负责“可靠队列”

  • 不强绑定任何具体埋点平台

也就是说:

  • 你可以接自研后端

  • 也可以接神策、GrowingIO、友盟之类的上报层

  • 甚至可以一套队列对接多种 uploader

  • Flutter 侧完整使用流程

下面以“初始化队列 -> 写入埋点 -> 合适时机上传 -> 导出失败事件”这个常见场景举例。


import 'package:reliable_event_queue/reliable_event_queue.dart';

  


class MyUploader implements EventUploader {

@override

Future<BatchUploadResult> upload(List<QueuedEvent> events) async {

// 这里接你自己的后端接口

return BatchUploadResult(

results: events

.map(

(event) => EventUploadResult(

eventId: event.id,

success: true,

),

)

.toList(growable: false),

);

}

}

  


final queue = ReliableEventQueue.instance;

  


await queue.initialize(

config: const QueueConfig(

batchSize: 20,

flushInterval: Duration(seconds: 30),

idleDuration: Duration(seconds: 10),

maxRetryCount: 5,

),

uploader: MyUploader(),

storage: SqliteQueueStorage(

dbPath: '/your/path/events.db',

),

);

  


await queue.track(

eventName: 'page_view',

payload: <String, dynamic>{

'pageKey': 'login',

'timestamp': DateTime.now().toIso8601String(),

},

);

  


final result = await queue.flush(force: true);

  


final failedPath = await queue.exportFailedEvents(

filePath: '/your/path/failed_events.jsonl',

);

如果你是 ViewModel / Store 架构,建议这样组织:

  • 页面只负责触发埋点

  • 上报时机由应用层统一管理

  • uploader 放到 data/service 层

  • 调试页读取 statsStream 和失败事件列表

  • 队列、重试与持久化实现思路

这个插件真正的核心,不在 track(),而在“事件进入队列后发生了什么”。

整体链路大致是:

  1. 业务生成 TrackEvent

  2. 插件包装成 QueuedEvent

  3. 写入内存队列

  4. 同步持久化到本地存储

  5. 到达上传时机后,选出一批可上传事件

  6. 调用 EventUploader.upload()

  7. 根据返回结果更新状态

  8. 成功则删除或标记成功,失败则计算下次重试时间

这里面我比较在意的几个点是:

  • 1. 持久化优先于上传

事件不是“准备上传时再存”,而是“进入队列时就先落地”。

这样做的价值很直接:

  • App 闪退不丢

  • 切后台不丢

  • 临时断网不丢

  • 2. 批量 flush,而不是一条条发

单条即发的问题很多:

  • 网络开销大

  • 并发难控

  • 服务端压力高

  • 一旦高频埋点出现,容易把上报链路打爆

所以插件里默认是批量挑选候选事件,再一次性交给 uploader 处理。

  • 3. 失败重试要有节制

插件里使用的是指数退避思路:

  • 第一次失败,稍后重试

  • 第二次失败,拉长间隔

  • 第三次继续拉长

达到 maxRetryCount 后,就不再无限重试,而是标记成 deadLetter

这类事件的意义在于:

  • 不影响正常队列继续跑

  • 又保留问题现场,便于后续导出排查

  • 4. 队列状态必须可观测

如果一个队列插件只能“悄悄工作”,那出问题时会非常难查。

所以这里单独补了:

  • QueueStats

  • statsStream

  • debugStream

  • 导出能力

这样你可以知道:

  • 当前有多少待上传

  • 多少上传成功

  • 多少失败

  • 是否已经进入死信

  • 最近一次 flush 到底发生了什么

  • 存储层设计

当前插件支持两种存储:

  • FileQueueStorage

  • SqliteQueueStorage

它们的定位不太一样:

  • 文件存储更轻,适合快速接入和小规模场景

  • SQLite 更稳,适合正式业务和较大事件量场景

这也是为什么我把存储层抽成了 QueueStorage 接口。

这样做的好处是:

  • 存储策略可替换

  • 核心队列逻辑不依赖具体存储实现

  • 后面如果要扩展 Hive / Isar / Drift,也不需要重写整套队列逻辑

  • 业务项目里怎么落地

如果你想把这个插件接进真实项目,我更建议先把它当成“埋点基础层”,而不是一上来就替换所有统计代码。

比较稳的落地方式是:

  1. 先接关键链路事件

  2. 再接页面曝光与点击

  3. 最后补调试与导出能力

例如在业务里,可以优先接这些场景:

  • 登录注册链路

  • 支付结果链路

  • AI 对话开始/结束/异常

  • 蓝牙同步成功/失败

  • 音频上传成功/失败

  • OTA 升级结果

这些事件有个共同特点:

  • 不是简单的“埋一下看看”

  • 而是真的需要可靠上报

如果拿 wisenote 这种项目举例,reliable_event_queue 特别适合用来承接:

  • 弱网下的关键行为事件

  • 音频、同步、上传等过程型日志

  • AI 交互链路事件

  • 需要后续导出排查的问题事件

  • 生命周期配合建议

插件本身没有把网络监听、前后台监听写死在内部,而是提供了几个钩子:

  • onAppForeground()

  • onAppBackground()

  • updateNetworkState()

  • notifyUserInteraction()

这是我刻意保留的边界。

因为不同项目对“什么时候上传”要求不一样。

例如:

  • App 回前台时 flush 一次

  • 网络恢复时 flush 一次

  • 用户空闲一段时间再 flush

  • 批量积压达到阈值后立即 flush

也就是说:

  • 插件负责提供时机接入点

  • 项目负责决定触发策略

  • 调试页为什么重要

做这类插件时,一个很容易被忽略的问题是:

  • 开发阶段你以为事件发出去了

  • 测试阶段你发现服务端没收到

  • 线上阶段你根本不知道是没写入、没上传、还是上传失败

所以插件里我一直保留“调试能力优先”的思路。

至少要能看到:

  • 当前队列总数

  • pending / failed / dead-letter 数量

  • 最近一次 flush 的结果

  • 失败事件导出文件

只有这样,这个插件在真实项目里才不是“黑盒”。

  • 常见误区
  • 误区1:把队列插件当成网络 SDK

这个插件解决的是“可靠缓冲与重试”,不是替你实现所有网络层能力。真正发请求的细节,还是应该交给 uploader。

  • 误区2:所有事件都一视同仁

埋点也要分层。

例如:

  • 支付成功、升级失败这种属于高优先级

  • 页面点击属于普通优先级

  • 某些调试类过程日志甚至可以采样

如果不分层,队列很容易被低价值事件淹没。

  • 误区3:失败后无限重试

无限重试听起来“更可靠”,但实际上会带来:

  • 电量消耗

  • 存储膨胀

  • 死循环问题

所以一定要有 deadLetter 兜底。

  • 误区4:没有调试和导出能力

没有这层能力,插件出问题时几乎不可排障。

  • 误区5:把业务策略写死在插件里

插件负责给通用能力,项目层负责决定:

  • 什么时候 flush

  • 哪些事件进队列

  • 哪些事件允许丢弃

  • 哪些事件必须保留成功记录

  • 项目中的架构总结

这轮改造后,埋点可靠上报相关逻辑已经形成了比较清晰的链路:

  • 插件层提供统一可靠队列能力

  • uploader 层负责对接真实后端

  • app 生命周期负责决定 flush 时机

  • 业务层只负责产生日志对象

  • 调试页和导出能力负责排障与验证

这样做的价值是:

  • 多项目可复用

  • 埋点逻辑不再散落在页面里

  • 弱网和异常场景下更稳

  • 排查问题时更有抓手

  • 后续切换后端成本更低

结束:

埋点这件事,真正难的点从来都不是“把一条数据发出去”,而在于:

  • 关键事件能不能先可靠记下来

  • 弱网和前后台切换时能不能不丢

  • 上传失败后能不能继续追踪和排障

  • 业务代码能不能不被一堆上报细节污染

reliable_event_queue 这类插件真正有价值的地方,也不在“多了一个 API”,而在于它把埋点这件事从“页面零散请求”拉回到了“基础设施能力”。

这个插件目前已经具备基础可用能力,后续还可以继续做:

  • 更完整的 isolate worker

  • 平台级网络与生命周期自动适配

  • 事件优先级调度

  • 去重与采样能力

  • 更完整的 debug widget

如果你项目里也有弱网、离线、上传、同步、实时链路这类场景,建议尽早把“埋点可靠性”从页面逻辑里抽出来,收益会比想象中大。

声明:

仅开源供大家学习使用,禁止从事商业活动,如出现一切法律问题自行承担。

仅学习使用,如有侵权,造成影响,请联系删除,谢谢。