失败的事件也需要送达
埋点最常见的实现是“发出去就不管”:fetch 失败、页面关闭、网络切换,事件就消失了。平时看不出问题,一旦线上出现故障,团队打开报表却发现关键路径的曝光和点击都缺了一段,无法判断问题从哪一步开始。
补报队列要解决的就是这段缺失。它把失败事件暂存在端上,等网络恢复或下次启动时重发。听起来只是“加一个数组”,真正难的是四件事:同一个事件不能重复计数、队列不能无限增长、旧事件不能永远占着位置、关键事件不能被噪声挤掉。
本节实现一个不依赖浏览器存储和真实网络的补报队列。时间、容量、TTL 都由外部注入,测试可以用普通对象断言淘汰和去重行为。
队列的四个约束
| 约束 | 问题 | 本实现的策略 |
|---|---|---|
| 去重 | 重试与重启导致同一事件重复上报 | 稳定 id + 内存去重集合 |
| 容量 | 长时间离线导致内存或存储膨胀 | 固定容量,满时按优先级淘汰 |
| 时效 | 三天前的曝光事件已无分析价值 | TTL 内保留,过期即丢弃并记住 id |
| 优先级 | 崩溃与噪声争抢有限空间 | p0 > p1 > p2,低优先级先被挤掉 |
去重 id 必须在事件创建时生成并持久化,不能等到发送时再生成。否则进程重启后同一条业务事件会拿到新 id,服务端无法判断是不是重复。去重集合本身也要有上限,否则它会在另一个维度上无限增长。
TTL 的计时起点是事件发生时间,不是入队时间。一个在离线状态排了两小时才入队的事件,不应该因为“刚入队”就重新获得完整生命周期。
补报队列实现
// file: src/error/telemetry-queue.ts
// tier: L1
// verify: node --test test/error/telemetry-queue.test.ts
export type EventPriority = "p0" | "p1" | "p2";
export interface TelemetryEvent {
/** 端上生成的稳定 id,重试与重启后都不变 */
id: string;
name: string;
/** 事件真正发生的时间,不等于入队时间 */
atMs: number;
priority: EventPriority;
payload?: Record<string, unknown>;
}
export interface EnqueueReport {
status: "queued" | "duplicate" | "rejected";
/** 因容量被挤掉的旧事件 id */
evicted: string | null;
}
export interface TelemetryQueueOptions {
capacity: number;
/** 事件在队列里允许停留的最长时间 */
ttlMs: number;
now: () => number;
/** 已发送 id 的记忆上限,用于跨批次去重 */
dedupeMemory?: number;
}
export interface SendAdapter {
send(batch: TelemetryEvent[]): Promise<boolean>;
}
export interface FlushReport {
sent: number;
remaining: number;
attempts: number;
stoppedBy: "transport" | null;
}
export type RecoveryAction = "retry" | "later" | "back";
export interface RecoveryOption {
action: RecoveryAction;
label: string;
primary: boolean;
}
const PRIORITY_RANK: Record<EventPriority, number> = {
p0: 0,
p1: 1,
p2: 2,
};
function rankOf(event: TelemetryEvent): number {
return PRIORITY_RANK[event.priority];
}
function compareEvents(a: TelemetryEvent, b: TelemetryEvent): number {
const byPriority = rankOf(a) - rankOf(b);
if (byPriority !== 0) return byPriority;
return a.atMs - b.atMs;
}
/**
* 失败埋点的补报队列。入队即去重,超时即淘汰;容量满时优先挤掉
* 低优先级里最旧的事件,p0 不会被新来的低优先级事件顶掉。
*/
export class TelemetryQueue {
private readonly capacity: number;
private readonly ttlMs: number;
private readonly now: () => number;
private readonly dedupeMemory: number;
private readonly seen: Set<string>;
private readonly seenOrder: string[];
private items: TelemetryEvent[];
constructor(options: TelemetryQueueOptions) {
if (options.capacity <= 0) throw new Error("capacity 必须为正数");
if (options.ttlMs <= 0) throw new Error("ttlMs 必须为正数");
this.capacity = options.capacity;
this.ttlMs = options.ttlMs;
this.now = options.now;
this.dedupeMemory = options.dedupeMemory ?? 200;
this.seen = new Set();
this.seenOrder = [];
this.items = [];
}
get size(): number {
this.pruneExpired();
return this.items.length;
}
pending(): TelemetryEvent[] {
this.pruneExpired();
return [...this.items].sort(compareEvents);
}
enqueue(event: TelemetryEvent): EnqueueReport {
this.pruneExpired();
if (this.seen.has(event.id) || this.items.some((item) => item.id === event.id)) {
return { status: "duplicate", evicted: null };
}
if (this.now() - event.atMs > this.ttlMs) {
this.remember(event.id);
return { status: "rejected", evicted: null };
}
if (this.items.length >= this.capacity) {
const victim = this.evictionCandidate();
if (victim === undefined || rankOf(event) >= rankOf(victim)) {
return { status: "rejected", evicted: null };
}
this.items = this.items.filter((item) => item.id !== victim.id);
this.remember(victim.id);
this.items.push(event);
this.remember(event.id);
return { status: "queued", evicted: victim.id };
}
this.items.push(event);
this.remember(event.id);
return { status: "queued", evicted: null };
}
/**
* 按优先级分批发送。成功一批才移除一批;任意一批失败立即停止,
* 剩余事件保留在队列里等待下一次补报。
*/
async flush(sender: SendAdapter, batchSize = 20): Promise<FlushReport> {
if (batchSize <= 0) throw new Error("batchSize 必须为正数");
this.pruneExpired();
let sent = 0;
let attempts = 0;
let stoppedBy: "transport" | null = null;
while (this.items.length > 0) {
const batch = this.pending().slice(0, batchSize);
attempts += 1;
let accepted = false;
try {
accepted = await sender.send(batch);
} catch {
accepted = false;
}
if (!accepted) {
stoppedBy = "transport";
break;
}
const sentIds = new Set(batch.map((event) => event.id));
this.items = this.items.filter((item) => !sentIds.has(item.id));
sent += batch.length;
}
return {
sent,
remaining: this.items.length,
attempts,
stoppedBy,
};
}
private evictionCandidate(): TelemetryEvent | undefined {
let candidate: TelemetryEvent | undefined;
for (const item of this.items) {
if (candidate === undefined) {
candidate = item;
continue;
}
const byRank = rankOf(item) - rankOf(candidate);
if (byRank > 0) {
candidate = item;
} else if (byRank === 0 && item.atMs < candidate.atMs) {
candidate = item;
}
}
return candidate;
}
private pruneExpired(): void {
if (this.items.length === 0) return;
const cutoff = this.now() - this.ttlMs;
const alive: TelemetryEvent[] = [];
for (const item of this.items) {
if (item.atMs < cutoff) {
this.remember(item.id);
} else {
alive.push(item);
}
}
this.items = alive;
}
private remember(id: string): void {
if (this.seen.has(id)) return;
this.seen.add(id);
this.seenOrder.push(id);
while (this.seenOrder.length > this.dedupeMemory) {
const oldest = this.seenOrder.shift();
if (oldest !== undefined) this.seen.delete(oldest);
}
}
}
/**
* 补报失败后给用户的可恢复操作。队列还在时允许重试或稍后,
* 「返回」只离开当前页面,不清空本地事件。
*/
export function recoveryOptions(report: FlushReport): RecoveryOption[] {
if (report.remaining === 0) return [];
if (report.stoppedBy === "transport") {
return [
{ action: "retry", label: "立即重试", primary: true },
{ action: "later", label: "稍后自动重试", primary: false },
{ action: "back", label: "返回并保留队列", primary: false },
];
}
return [
{ action: "retry", label: "重新发送", primary: true },
{ action: "back", label: "返回并保留队列", primary: false },
];
}
实现里有几个关键选择。
第一,入队前先清理过期事件。过期事件不会发送,但它的 id 会被记入去重集合,避免同一份过期数据被反复尝试入队。
第二,容量满时先找“淘汰候选”:优先级最低,同优先级里发生时间最早。只有新事件优先级严格高于候选时才允许挤掉它。这样 p0 队列满了以后,新的 p1、p2 会被拒绝,而不是把崩溃事件挤出去。
第三,flush 成功一批才移除一批。任意一批失败就立即停止,剩余事件原样保留。失败不增加 sent,也不清空队列,下一次补报从当前头部继续。
第四,pending() 返回排序后的副本,调用方无法通过修改返回值破坏内部顺序。排序键是优先级优先、发生时间其次,保证关键事件先发。
丢弃策略比容量更重要
很多实现只写 if (queue.length > 100) queue.shift()。这会把最早的事件丢掉,而最早的事件可能正好是崩溃前的最后一条 p0。容量只是上限,真正决定“什么值得保留”的是淘汰策略。
本实现把优先级放在时间之前:先比较优先级,再比较发生时间。它接受一个代价:同优先级的旧事件比新事件优先保留。对分析来说,旧事件更接近故障起点,保留它通常比保留最新一条噪声更有价值。如果产品更关心最近行为,可以把这条规则反过来,但必须显式写出来。
丢弃也不是无痕的。每次淘汰都通过 evicted 返回被挤掉的 id,调用方可以把它计入“端上丢弃计数”。这个计数和成功补报数一起看,才能判断队列容量是否合理:如果每天都大量淘汰 p1,说明容量太小或事件产生速度异常。
用户可恢复操作
补报失败后,界面不应该只显示一个“发送失败”。recoveryOptions 根据报告返回三类动作:
retry:立即再试一次,适合用户刚恢复网络、明确想现在提交的场景。later:保留队列,等下次启动或网络恢复自动重试,适合用户不想被打断。back:离开当前页面但不删除本地事件,队列留到下一次。
三个动作的共同点是“不清空队列”。清空应该是独立的管理操作,而不是失败提示的默认按钮。用户点“返回”时以为只是离开页面,如果实现顺手把队列删了,故障现场就永久丢失。
测试与真实输出
测试覆盖排序、重复入队、TTL 过期、容量淘汰、p0 保护、同优先级保留旧事件、分批发送、传输失败保留、部分成功、跨批次去重、空队列无可恢复操作。在实验工程根目录执行单文件命令:
node --test test/error/telemetry-queue.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ 入队后按优先级与发生时间排序 (1.037334ms)
✔ 同一个 id 重复入队只保留一条 (0.159916ms)
✔ 超过 ttl 的事件直接拒绝,已入队事件也会被清理 (0.07725ms)
✔ 容量满时挤掉低优先级里最旧的事件 (0.180334ms)
✔ p0 队列满了以后不再接受低优先级事件 (0.189166ms)
✔ 同优先级保留更旧的事件,新事件被拒绝 (0.092834ms)
✔ 按批次发送,成功后移除已发送事件 (0.198666ms)
✔ 传输失败时保留剩余事件并停止本轮 (0.146917ms)
✔ 部分批次成功后失败,报告保留未发送部分 (0.137667ms)
✔ 已发送 id 在内存上限内继续去重 (0.134375ms)
✔ 队列为空时没有可恢复操作 (0.066ms)
ℹ tests 11
ℹ suites 0
ℹ pass 11
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 227.474709
目录形式在 Node 26 会报 MODULE_NOT_FOUND,不要用 node --test test/error/。
一次埋点风暴的复盘
某次版本上线后,客服反馈“下单按钮点了没反应”。排查时发现下单接口错误率正常,但转化漏斗里“点击下单”事件少了 40%。根因是点击埋点和下单请求共用同一个失败处理:请求失败时,代码先清空了待发送事件,再弹错误提示。
更糟的是,那次故障产生的大量重试事件把队列填满,容量淘汰按入队顺序丢掉了最早的 p0 崩溃事件,只留下重复的 p1 点击。团队既看不到崩溃前的上下文,也无法判断重复点击来自用户还是重试逻辑。
修正分三步:给事件加稳定 id 和优先级;把淘汰策略改成低优先级优先;失败提示提供“稍后重试”,不再默认清空队列。上线后同样的故障里,p0 事件保留率从不足一半提升到接近全部,漏斗缺口也能和错误日志对上。
与错误边界的边界
错误边界处理“页面还能不能渲染”,补报队列处理“已经发生的事实还能不能送达”。两者都会在失败时被触发,但职责不同:边界决定展示什么,队列决定记住什么。
一个常见误区是把补报逻辑塞进错误边界的兜底组件。兜底组件可能被重复挂载、可能因为坏数据再次失败,埋点事件会跟着丢失或重复。正确做法是让埋点队列独立于渲染树,由应用级单例持有,边界只负责在合适的时候调用 flush。
小结
补报队列不是“失败后重发”这么简单,它要同时处理去重、容量、时效和优先级。稳定 id 解决重复计数,TTL 解决陈旧数据,容量和淘汰策略解决无限增长,优先级解决关键事件被噪声挤掉。
用户可恢复操作的核心是保留队列。重试、稍后、返回都不应该清空事件;清空必须是显式的管理动作。把这些规则写进实现和测试,故障现场才不会在最需要的时候缺一块。
阅读导航:上一节:11.2 弱网与接口失败兜底 · 下一节:12.1 性能指标口径 。
常见错误与边界
- 发送时才生成事件 id。进程重启后同一条业务事件会变成两条,服务端无法去重。
- 去重集合没有上限。修复了队列膨胀,却在另一个地方制造了内存泄漏。
- TTL 从入队时间算起。排队很久的事件会重新获得完整生命周期,失去时效控制的意义。
- 容量满时直接丢最早事件。最早的事件可能正是故障前的 p0,淘汰必须先看优先级。
- 允许低优先级事件挤掉 p0。关键事件保护一旦破例,队列就退化成先进先出。
- 失败提示默认清空队列。用户点“返回”不等于同意删除本地事件。
- 把补报逻辑放进兜底组件。兜底可能重复挂载或再次失败,队列必须独立于渲染树。
- 只看补报成功数,不看淘汰数。没有淘汰计数,就无法判断容量是否合理。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。