《互联网产品的客户端架构·端形态与体验基础》11.3 埋点补报与可恢复性

埋点失败后直接丢弃,会让故障现场永远缺一块。本节实现一个带 TTL、容量上限、优先级淘汰与跨批次去重的补报队列,说明 p0 事件如何避免被噪声挤掉,以及补报失败后重试、稍后、返回三种用户可恢复操作的分工,最后复盘一次埋点风暴的全过程与容量参数。

失败的事件也需要送达

埋点最常见的实现是“发出去就不管”: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。关键事件保护一旦破例,队列就退化成先进先出。
  • 失败提示默认清空队列。用户点“返回”不等于同意删除本地事件。
  • 把补报逻辑放进兜底组件。兜底可能重复挂载或再次失败,队列必须独立于渲染树。
  • 只看补报成功数,不看淘汰数。没有淘汰计数,就无法判断容量是否合理。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查