ELL3N HSU

Available in Chinese only.

← 文章

Frontend Insight:事件怎样经过校验、重试与消费

沿浏览器队列、接收治理、Kafka 与 consumer 解释事件身份、重试、offset 和读侧去重,并区分接收成功与持久化成功。

浏览器说事件已经发出,API 也返回了成功,为什么数据库里还没有?因为它们确认的是不同阶段:浏览器接受发送,API 把消息交给 Kafka,consumer 再写入存储,最后查询才能在指定范围内读到。沿一条事件看这些确认点,也就能看清失败后该在哪里重试。

下面按这条路径解释契约、重试与消费,采用系列固定代码基线,包含诊断扩展后的大小预算和失败回队行为。

三种结构,不要混为一个 JSON

结构源码字段与用途
浏览器批次event-batch-v3.schema.jsonschemaVersion/sentAt/sdk/events;事件有身份、页面、环境、时间和 payload
Kafka envelopemodel.ts 的 KafkaEventEnvelope增加 envelopeVersion/projectId/receivedAt/requestId/origin/enrichments,由服务端生成
ClickHouse 行consumer 的 rows将常用过滤/归约字段展开为列,保留 payload_json;不是原样保存浏览器请求

schemaVersion 是传输结构版本;SDK 版本是软件发布版本;workflowDefinitionVersion、质量事实 qualityVersion 又有各自用途。它们不能互相替代。

当前只支持契约 v3。字段与规范名称的修改入口是 canonical-names.json 和 generate-types.mjs;JSON Schema 和 src/generated 类型均为生成结果,不要只修改生成文件。根命令 pnpm contract:generate 生成,pnpm contract:check 检查生成结果是否与源文件一致。

校验为什么分三层

boundaries.ts 的 validateForProducer/validateForIngestion/validateForConsumer 都调用 validateTransportBatch。SDK 的浏览器运行时还有自己的规范化与边界处理,不能理解成所有环境都直接加载服务端 Ajv 校验器。

共同校验包含:

  • v3 JSON Schema 与字段关系;当前批次不超过 50 条、128 KiB,去掉 diagnostic 后的基础事件不超过 8 KiB。诊断信封另受 64 KiB 总预算以及栈、console、请求等分项预算约束。
  • 一个批次只能有一个 appId + env,批次内 eventId 唯一。
  • 普通字段执行凭据和直接识别信息检测;指定诊断 raw 区域按独立合同允许保留原文,不能把豁免扩展到全部 payload。整个批次必须可以 JSON 序列化。
  • 开启质量/使用采集时,相应计数、采样、序号和终态字段必须自洽。例如完成 API 数不能大于开始数。
  • 仅在传入 nowMs 时检查事件时钟窗口,默认允许与当前时间偏差不超过 24 小时。接收端传入当前时间;consumer 不用消费时的“现在”重新拒绝合法积压。

接收端另外检查项目有效性、Origin、SDK 策略、工作流定义、功能注册、组织目录、对象引用。消费端复核治理结果,防止被污染或绕过 API 的消息成为可信事实。

这里的校验必须实际运行。写下 as FrontendInsightEventBatch 只是在 TypeScript 中声明类型,不会检查外部 JSON 的字段,也就不能代替服务端入口的验证。

SDK 是有状态的小型运行时

从 createTracker 读起,再读 BrowserTracker。

createTracker 检查 appId、环境、release 和 endpoint。无效配置返回 noop tracker;同一 appId/env/release/endpoint 的存活实例会复用。这样重复初始化不至于重复安装同一套采集器。调试时查看 getDiagnostics(),不要仅以“初始化没抛异常”判断成功。

生命周期关键动作维护关注点
创建建立 device/session/page view,装配工作流、表单和可选质量采集,安装监听initialUserId 尽早建立身份;SDK 自身失败应隔离
页面/路由变化结算旧页面,建立新 pageViewIdSPA 不是整页刷新,不能把不同路由的可见时间混算
可见性变化累积前台可见时间、发送阶段结算标签页在后台的墙钟时间不能当作有效停留
setUser用户变化前结算表单,再更新身份操作句柄和工作流不能无条件跨账号延续
操作/工作流保存实例状态,生成明确开始和终态多次点击与一个业务操作、多步流程并非同一概念
flush从内存队列分批发送传输失败记诊断,不抛到宿主业务
destroy结束采集/监听、结算并发起生命周期 flush是尽力发送,不是等待服务端持久化的同步承诺

三种业务接口需要分别理解

  • featureStarted/featureSucceeded/... 表达显式功能阶段。上报成功的时机由业务代码判断,不能用点击事件自动代替。
  • startOperation 返回操作句柄,以 operationInstanceId 关联开始和终态。注册要求启用操作生命周期的功能,接收端会拒绝缺实例的生命周期事件。
  • startWorkflow 由 WorkflowRuntime 持有工作流实例、定义版本、步骤和终态。它支持显式开始或首步开始、步骤触发和超时;服务端 workflow-definitions 会按已发布定义再校验。只按 session 或时间邻近推测步骤归属会破坏关联。

长时功能通过 startLongView 结算前台可见时长;表单和业务结果分别见 forms.ts 与 tracker.ts。扩展接口前,先用已有测试确认它属于哪个状态机。

队列与发送的真实保证

createTracker 默认每 10 秒 flush;队列默认且最高为 100 条,满队列时丢弃最老条目并记录诊断。nextBatch 按 50 条/128 KiB 限制分包。队列在内存中,没有断电恢复或持久离线补传保证。

事件从队列取出后,send 会根据发送时机、批次大小和失败类型选择路径:

  1. 生命周期发送且序列化正文不超过 60 KiB 时优先 sendBeacon;若浏览器接受入队就视为本次发送成功。更大的批次直接使用普通 fetch,不能假定它也拥有页面退出后的投递保证。
  2. Beacon 未入队时回退 fetch;keepalive 仍只在上述生命周期与大小条件下开启。
  3. fetch 最多尝试 3 次,间隔为 50ms、100ms;网络异常和非 4xx 失败可重试,所有 4xx,包括 429,都直接停止该批发送。
  4. 批次失败后记录 failedBatches。网络异常或服务端失败重试耗尽时,send 返回 undefined;flush 将原事件放回有界内存队列头部,结束本轮发送,后续 flush 可使用原 eventId 再次投递。永久 4xx 返回 false,不回队。

这就解释了为什么 sentEvents 不能当作 ClickHouse 的确认计数。Beacon 接受入队时,甚至还没有 API 202。暂时失败虽然可以回队,但队列仍在内存中;页面关闭、tracker 销毁或队列溢出后,事件仍可能丢失。

身份有不同层级:eventId 标识事实,pageViewId 标识页面访问,sessionId 标识会话,deviceId 是未登录使用分析的设备兜底,userId 是经过约束的业务用户引用。UV 的去重键应按具体读模型查看,不能把设备 UV 写成真实账号人数。

接收端:将不可信事件变成可消费消息

跟读 IngestionManager.accept/sanitize:

  1. 检查大小、契约和时钟;按 appId 读取项目配置。
  2. 确保项目 active,Origin 存在并精确匹配项目白名单;校验探针版本策略。
  3. 以项目、Origin、IP 为键限流。限流器、项目缓存和诊断计数都在 API 进程内,多副本不会自动共享。
  4. 校验工作流及功能;读取目录,生成可信 enrichments。
  5. 用 HMAC-SHA256(ACCOUNT_HMAC_KEY, projectId + ':' + userReference) 转换用户引用;同一引用在不同项目下结果不同。
  6. 用事件发生时刻、环境和当时可见的已发布组织目录覆盖 deptId/roleId;浏览器声称的部门不拥有权威性。
  7. 有诊断信封时先经独立准入并记录 receipt,准入依赖失败会返回 503;随后发布 Kafka,成功后尽力更新接收状态,再返回 202 和 requestId。

CORS 预检响应和业务 Origin 白名单是两件事。OPTIONS 返回允许头不意味着 POST 已授权;真实检查在 assertProject。采集公开路由也不需要把管理端 bearer token 嵌入业务网页。

重复操作为什么有另一条短期通道

重复操作需要判断是不是对同一业务对象的操作,但对象关联材料不应长时间混在全部事件里。object-reference.ts、repeated-projection.ts 与 sanitize 共同完成:

  • 受约束的业务开始事件携带已签发引用;验证用户、会话、实例、操作、环境等上下文。
  • 从长通道 payload 删除 objectReference,留下 repeatedEligible 等事实标记。
  • publishWithObjects 在一个 Kafka 事务中同时发布短期对象材料与普通 envelope,失败则中止事务。
  • consumer 订阅短期主题并产生重复证明。长通道若仍有 objectReference,会拒绝为 OBJECT_REFERENCE_IN_LONG_CHANNEL。

这不是 MySQL、Kafka、ClickHouse 的分布式事务;Kafka 事务只覆盖其两条消息发布。短期表及查询保护见下一章。

Consumer:持久化成功后才能推进 offset

EventConsumerRuntime.start/eachBatch 当前配置是 autoCommit: true、autoCommitThreshold: 1、eachBatchAutoResolve: false。不要把历史文档中的“手动提交”简化理解成当前 autoCommit: false。

对普通主题,处理顺序如下:

合法事件写入事实与状态,非法消息记录死信元数据;成功后确认 offset,依赖故障不确认。合法事件写入事实与状态,非法消息记录死信元数据;成功后确认 offset,依赖故障不确认。
按批次处理:毒消息只写死信元数据,合法项完成事实、诊断详情和状态持久化后,整批才确认 offset。短期对象主题另有路径。

宽图可横向滚动。查看原图查看原图

消费失败时,接下来是跳过消息还是等待恢复,要先看失败发生在哪里:

  • 毒消息指结构或业务证据确实非法;DLQ 写入成功后才能跳过它。DLQ 只保存来源 offset、错误码、hash、可验证的 projectId 等元数据,没有原始 payload,不能指望用 DLQ 本身重建事件。
  • MySQL 元数据读取失败、ClickHouse 写失败属于依赖故障,不能据此将正常业务消息丢进 DLQ。达到重试阈值后暂停分区,稍后恢复。
  • 先完成批次写入及状态更新,再 resolve 全批 offset,避免后面的毒消息先提交导致前面的未写事实被跳过。
  • Kafka 事务有不可见控制记录;当前提交逻辑也考虑控制记录推进和未处理后缀。不要用“最后看到的事件 offset + 1”自行重写提交。

短期主题每次最多处理一条 Kafka 消息,其中对象最多 50 个;完成投影或确定毒消息并写 DLQ 后才 resolve。消费者设置 readUncommitted: false,不读取未提交事务内容。

幂等不是“数据库里永远只有一条”

发布器启用 Kafka producer 幂等,不等于 SDK 重发同一 eventId 永远不会形成新消息。若 ClickHouse 写成功后进程退出、offset 未提交,重启会重写。raw_events 本身是普通 MergeTree,允许重复。

因此,同一事件可能在原始表中出现多行,查询或 reducer 需要按 eventId 去重;部分事实 reducer 还会把同 ID 的内容冲突判为不可用。这是至少一次消费配合读侧去重。新增读模型如果直接使用 count(*),就可能把一次基础设施重试算成一次新的业务活动,不能按端到端 exactly-once 来理解它。

用失败路径检查传输承诺

下面是 send 与 flush 控制关系的教学伪代码,省略分包、计数和容量裁剪。三态返回值区分成功、永久失败与暂时失败耗尽:

const result = await send(serializedBatch);
if (result === true) {
  recordSentAttempt(batch);
} else if (result === undefined) {
  requeueWithinMemoryLimit(batch.events); // 保留原 eventId
  stopCurrentFlush();
} else {
  recordPermanentFailure(batch); // 4xx 不反复回队
}

如果请求已经进入 Kafka、但浏览器没有收到响应,下一次 flush 仍可能重发。因此,保留事件 ID 和读侧去重必须一起成立;增加重试次数不能独立解决结果一致性。另一方面,持久化失败时必须让 offset 留在未确认位置,不能把正常消息作为毒消息跳过。

consumer 也不把 envelope 的组织声明直接当权威。没有 directoryVersionId 的旧消息可以保留业务事实,但清空组织归属;有版本时必须与可信目录、用户和部门一致。这使事件传输与业务身份治理各自有复核边界。

用于验证这些行为的入口包括 pipeline.test.ts 与 consumer 测试。在源码仓库安装依赖后可运行:

pnpm exec vitest run packages/server-core/test/pipeline.test.ts apps/consumer

单元测试可以检查 Kafka 发布失败不返回成功、写库失败不提前 resolve,以及非法消息的 DLQ 顺序;真实 Kafka/ClickHouse 的故障恢复仍需要集成环境。若拿到 202 却查不到数据,沿 requestId、消费积压、原始事实、项目与环境、事件时间和查询快照逐步缩小范围,具体读模型见下一篇。