《互联网产品的客户端架构·端形态与体验基础》12.3 埋点规范与数据质量

埋点质量决定端上观测的上限。本节把事件命名、必填字段、类型校验、时间戳与 session 规则写成可运行的注册表与校验器,解释如何用发送数与接收数检测丢失,并用 schema 对比管理口径变更,避免一次字段改动静默破坏历史数据与下游报表口径。

埋点事故很少是“没上报”

端上观测做久了会发现,最难的从来不是把事件发出去,而是保证发出去的事件能被正确解释。字段名从 cardId 变成 card_id,报表就多出一列空值;必填字段开始传空字符串,校验看起来通过、分析时全是噪声;客户端升级了 schema 而数仓没跟上,新旧数据被拼成一条曲线。

本节把埋点规范写成注册表与校验器:事件名怎么起、必填字段怎么查、时间戳和 session 怎么验、发送数与接收数怎么对、schema 变更怎么分级。代码是纯函数,不依赖真实上报 SDK、网络或时钟。

事件命名:命名空间、小写下划线、显式版本

事件名采用 命名空间.动作 的形式,例如 feed.card_tap、order.submit。规则是每段小写、单词之间用下划线、至少两段。强制命名空间的原因很实际:没有命名空间,click、exposure 这类通用名会在不同业务之间碰撞;有了命名空间,报表可以按前缀聚合,也不会因为两个团队都叫 submit 而串数据。

版本放在独立字段,不放进事件名。把版本写进名字会让同一个事件在数仓里变成多张表,查询方必须知道所有历史版本才能拼出完整序列。独立版本字段则可以在同一张表里按版本过滤。

必填字段:声明、类型与空值

必填字段必须同时出现在 required 和 properties 里。只写 required 不写类型,等于声明了一个无法校验的约束;只写类型不写 required,则字段缺失时会被当成合法。注册表在创建时就拒绝这两种半成品。

校验分三层:缺失(undefined 或 null)、类型不符、空值。字符串的 "" 和纯空白都算空值,因为它们会让“有值率 100%”的报表看起来正常。数字必须有限,NaN 和 Infinity 不能进入统计。布尔值只接受真正的布尔,不接受 "true" 字符串。

未声明字段不直接判错,而是给警告。原因是客户端可能先上线新字段、schema 后登记,直接拒绝会造成数据真空;但警告必须进入审计报表,否则“临时字段”会永久留下。废弃字段同理:还能用,但每次出现都提醒迁移。

丢失检测:只能说明“少收到了”

发送数和接收数的差额只能说明链路上少了多少条,不能证明丢在哪一跳。可能是客户端没发、网络失败、网关拒绝、入库失败,也可能是重复入库导致接收数反而大于发送数。所以 detectLoss 遇到接收数大于发送数时直接抛错,而不是算出一个负的丢失率。

默认阈值设为 2%,这是案例假设值。低于阈值不一定正常,高于阈值一定需要排查。worstBatchId 用来把排查范围缩小到一个批次,而不是从几十万条日志里从头翻。

口径变更:先分级,再发版

schema 变更分两类。删除字段、修改类型、新增必填字段都是 breaking:客户端、数仓、报表必须同时升级,否则历史数据无法拼接。新增选填字段、把必填改为选填是 compatible:旧数据仍然可读,新数据多一列。

版本号必须随变更递增。diffSchemas 在发现变更但版本没涨时单独报一条 breaking,因为这是最容易被忽略、也最容易造成“同名不同义”的情况。变更记录要写进提交信息,并在报表上标注切换日期。

代码

下面的文件包含注册表、事件校验、schema 对比、丢失检测与批量审计。所有判断都是纯函数,测试可以直接构造事件,不需要启动上报 SDK。

// file: src/obs/tracking-spec.ts
// tier: L1
// verify: node --test test/obs/tracking-spec.test.ts

export type PropertyType = "string" | "number" | "boolean";

export interface EventSchema {
  name: string;
  version: number;
  /** 必填字段必须同时声明在 properties 里,避免“必填但没类型” */
  required: string[];
  properties: Record<string, PropertyType>;
  deprecated?: string[];
}
export interface TrackingEvent {
  name: string; version: number; occurredAtMs: number;
  sessionId: string; userId?: string; props: Record<string, unknown>;
}
export interface ValidationResult { event: string; ok: boolean; errors: string[]; warnings: string[]; }
export interface SchemaRegistry {
  get(name: string, version: number): EventSchema | undefined;
  latest(name: string): EventSchema | undefined;
  names(): string[];
}
export interface SchemaChange { field: string | null; kind: string; severity: "breaking" | "compatible"; note: string; }
export interface BatchReceipt { batchId: string; sentCount: number; receivedCount: number; sentAtMs: number; }
export interface LossReport {
  batches: number; sent: number; received: number; missing: number;
  lossRate: number; suspicious: boolean; worstBatchId: string | null;
}
export interface AuditSummary { total: number; passed: number; failed: number; failures: ValidationResult[]; }

const EVENT_NAME_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
const SESSION_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;
const FUTURE_TOLERANCE_MS = 5 * 60_000;

export function isValidEventName(name: string): boolean {
  return EVENT_NAME_PATTERN.test(name);
}

function assertSchema(schema: EventSchema): void {
  if (!isValidEventName(schema.name)) {
    throw new Error(`createRegistry: 事件名不合法 ${schema.name}`);
  }
  if (!Number.isInteger(schema.version) || schema.version < 1) {
    throw new Error(`createRegistry: ${schema.name} 的版本必须是正整数`);
  }
  for (const field of schema.required) {
    if (!(field in schema.properties)) {
      throw new Error(`createRegistry: ${schema.name} 的必填字段 ${field} 没有声明类型`);
    }
  }
  for (const field of schema.deprecated ?? []) {
    if (!(field in schema.properties)) {
      throw new Error(`createRegistry: ${schema.name} 的废弃字段 ${field} 不存在`);
    }
  }
}

export function createRegistry(schemas: readonly EventSchema[]): SchemaRegistry {
  const byKey = new Map<string, EventSchema>();
  const latest = new Map<string, EventSchema>();

  for (const schema of schemas) {
    assertSchema(schema);
    const key = `${schema.name}@${schema.version}`;
    if (byKey.has(key)) throw new Error(`createRegistry: 重复的 schema ${key}`);
    byKey.set(key, { ...schema, properties: { ...schema.properties } });
    const current = latest.get(schema.name);
    if (current === undefined || schema.version > current.version) {
      latest.set(schema.name, byKey.get(key) as EventSchema);
    }
  }

  return {
    get(name, version) {
      return byKey.get(`${name}@${version}`);
    },
    latest(name) {
      return latest.get(name);
    },
    names() {
      return [...latest.keys()].sort();
    },
  };
}

function checkProperty(field: string, value: unknown, type: PropertyType): string | null {
  if (value === undefined || value === null) return `必填字段 ${field} 缺失`;
  if (type === "string") {
    if (typeof value !== "string") return `字段 ${field} 应为 string,收到 ${typeof value}`;
    return value.trim() === "" ? `必填字段 ${field} 为空字符串` : null;
  }
  if (type === "number") {
    return typeof value === "number" && Number.isFinite(value)
      ? null
      : `字段 ${field} 应为有限 number,收到 ${String(value)}`;
  }
  return typeof value === "boolean"
    ? null
    : `字段 ${field} 应为 boolean,收到 ${typeof value}`;
}

export function validateEvent(
  event: TrackingEvent,
  registry: SchemaRegistry,
  options: { nowMs?: number } = {},
): ValidationResult {
  const errors: string[] = [];
  const warnings: string[] = [];
  const schema = registry.get(event.name, event.version);

  if (schema === undefined) {
    const next = registry.latest(event.name);
    errors.push(
      next === undefined
        ? `未登记的事件 ${event.name}`
        : `事件 ${event.name} 的版本 ${event.version} 未登记,当前最新为 ${next.version}`,
    );
    return { event: event.name, ok: false, errors, warnings };
  }

  if (!Number.isFinite(event.occurredAtMs) || event.occurredAtMs < 0) {
    errors.push("occurredAtMs 必须是非负有限数");
  } else if (options.nowMs !== undefined && event.occurredAtMs > options.nowMs + FUTURE_TOLERANCE_MS) {
    errors.push("occurredAtMs 超前当前时间超过 5 分钟");
  }
  if (!SESSION_PATTERN.test(event.sessionId)) {
    errors.push("sessionId 必须为 8 到 64 位字母、数字、下划线或短横线");
  }
  if (event.userId !== undefined && event.userId.trim() === "") {
    errors.push("userId 存在时不能为空");
  }

  for (const field of schema.required) {
    const message = checkProperty(field, event.props[field], schema.properties[field]);
    if (message !== null) errors.push(message);
  }

  for (const field of Object.keys(event.props).sort()) {
    if (!(field in schema.properties)) {
      warnings.push(`字段 ${field} 未在 schema 中声明`);
      continue;
    }
    if (schema.required.includes(field)) continue;
    const message = checkProperty(field, event.props[field], schema.properties[field]);
    if (message !== null) errors.push(message);
    if ((schema.deprecated ?? []).includes(field)) {
      warnings.push(`字段 ${field} 已废弃,下个版本移除`);
    }
  }

  return { event: event.name, ok: errors.length === 0, errors, warnings };
}

/**
 * 对比两个版本的 schema,并把变更分成 breaking 与 compatible。
 * 新增必填字段、删除字段、改类型都要求客户端与报表同时升级,属于 breaking。
 */
export function diffSchemas(previous: EventSchema, next: EventSchema): SchemaChange[] {
  const changes: SchemaChange[] = [];
  if (previous.name !== next.name) {
    changes.push({
      field: null,
      kind: "event-renamed",
      severity: "breaking",
      note: `${previous.name} 改名为 ${next.name},旧数据无法直接拼接`,
    });
  }

  const fields = [
    ...new Set([...Object.keys(previous.properties), ...Object.keys(next.properties)]),
  ].sort();

  for (const field of fields) {
    const before = previous.properties[field];
    const after = next.properties[field];
    if (before !== undefined && after === undefined) {
      changes.push({ field, kind: "removed-property", severity: "breaking", note: `字段 ${field} 被删除` });
      continue;
    }
    if (before !== undefined && after !== undefined && before !== after) {
      changes.push({
        field,
        kind: "type-changed",
        severity: "breaking",
        note: `字段 ${field} 类型从 ${before} 改为 ${after}`,
      });
    }
    const wasRequired = previous.required.includes(field);
    const isRequired = next.required.includes(field);
    if (!wasRequired && isRequired) {
      changes.push({ field, kind: "required-added", severity: "breaking", note: `字段 ${field} 变为必填` });
    } else if (wasRequired && !isRequired) {
      changes.push({ field, kind: "required-removed", severity: "compatible", note: `字段 ${field} 改为选填` });
    }
    if (before === undefined && after !== undefined && !isRequired) {
      changes.push({ field, kind: "optional-added", severity: "compatible", note: `新增选填字段 ${field}` });
    }
  }

  if (changes.length > 0 && next.version <= previous.version) {
    changes.push({
      field: null,
      kind: "version-not-bumped",
      severity: "breaking",
      note: `schema 已变更但版本 ${next.version} 未高于 ${previous.version}`,
    });
  }
  return changes;
}

/** 发送数与接收数的差额只能说明“少收到了”,不能证明丢在哪一跳。 */
export function detectLoss(receipts: readonly BatchReceipt[], threshold = 0.02): LossReport {
  let sent = 0;
  let received = 0;
  let worstBatchId: string | null = null;
  let worstMissing = -1;

  for (const receipt of receipts) {
    if (receipt.batchId.trim() === "") throw new Error("detectLoss: batchId 不能为空");
    if (
      !Number.isInteger(receipt.sentCount) || !Number.isInteger(receipt.receivedCount) ||
      receipt.sentCount < 0 || receipt.receivedCount < 0
    ) {
      throw new Error(`detectLoss: ${receipt.batchId} 的计数必须是非负整数`);
    }
    if (receipt.receivedCount > receipt.sentCount) {
      throw new Error(`detectLoss: ${receipt.batchId} 的接收数大于发送数,可能是重复入库`);
    }
    const missing = receipt.sentCount - receipt.receivedCount;
    sent += receipt.sentCount;
    received += receipt.receivedCount;
    if (missing > worstMissing || (missing === worstMissing && worstBatchId !== null && receipt.batchId < worstBatchId)) {
      worstMissing = missing;
      worstBatchId = receipt.batchId;
    }
  }

  const missing = sent - received;
  const lossRate = sent > 0 ? missing / sent : 0;
  return {
    batches: receipts.length,
    sent,
    received,
    missing,
    lossRate,
    suspicious: lossRate > threshold,
    worstBatchId: worstMissing > 0 ? worstBatchId : null,
  };
}

export function auditBatch(
  events: readonly TrackingEvent[],
  registry: SchemaRegistry,
  options: { nowMs?: number } = {},
): AuditSummary {
  const failures: ValidationResult[] = [];
  for (const event of events) {
    const result = validateEvent(event, registry, options);
    if (!result.ok) failures.push(result);
  }
  return { total: events.length, passed: events.length - failures.length, failed: failures.length, failures };
}

测试与真实输出

在实验工程根目录执行:

node --test test/obs/tracking-spec.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

✔ 事件名必须是带命名空间的小写下划线格式 (0.747959ms)
✔ 注册表拒绝非法名、重复版本与未声明类型的必填字段 (0.40625ms)
✔ 规范事件通过校验 (0.98375ms)
✔ 缺失必填、类型错误、未声明字段分别进入错误与警告 (0.194584ms)
✔ 已废弃字段可用但会产生迁移警告 (0.095916ms)
✔ 版本不匹配与未知事件给出可定位的错误 (0.135541ms)
✔ 时间戳与 sessionId 有独立校验 (0.118167ms)
✔ schema 对比把删除、改类型、新增必填标为 breaking (0.1625ms)
✔ 新增选填字段兼容,改类型 breaking,漏升版本单独报错 (0.133042ms)
✔ 丢失检测按发送与接收总数计算,并找出最差批次 (0.182083ms)
✔ 全部收到时丢失率为零且不报警 (0.070208ms)
✔ 接收数大于发送数或计数非法时拒绝统计 (0.101292ms)
✔ 批量审计汇总通过数与失败明细 (0.115292ms)
ℹ tests 13
ℹ suites 0
ℹ pass 13
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 203.973458

一次“字段还在但全是空串”的事故

某次版本把来源字段从可选改成必填,客户端照常传了 source: ""。服务端校验只看“字段是否存在”,于是全部通过;一周后报表显示来源分布只有一个空值,运营以为渠道投放失效,实际是客户端根本拿不到来源。

如果当时跑了 validateEvent,空字符串会被单独报成“必填字段 source 为空字符串”,而不是通过。事故的根因不是字段缺失,而是“存在但无意义”被当成了有效值。校验器要能区分这两种状态,报表也要把空值率单独列出来。

小结

埋点规范的核心不是一份更长的字段表,而是可执行的约束:命名空间防止碰撞,注册表保证必填字段有类型,校验器区分缺失、类型错误与空值,丢失检测给出可排查的批次,schema 对比把变更分成 breaking 与 compatible。

规范只有进入构建和审计流程才有意义。写在文档里的规则会被绕过,写在函数里的规则会在每条事件上执行。下一章从观测回到交付,讨论回声客户端的基线。

阅读导航:上一节:12.2 崩溃与卡顿归因 · 下一节:13.1 回声客户端基线 。

常见错误与边界

  • 事件名不带命名空间。通用名会在业务之间碰撞,报表无法按前缀聚合。
  • 把版本写进事件名。同一事件会裂成多张表,历史序列难以拼接。
  • 必填字段只写 required 不写类型。约束存在,但无法校验。
  • 用“字段存在”代替“字段有值”。空字符串会让有值率看起来正常。
  • 数字字段接受 NaN 或 Infinity。后续均值与分位全部失效。
  • 未声明字段直接丢弃。新版本字段会在一段时间内完全不可见。
  • 接收数大于发送数仍算丢失率。这通常是重复入库,应该直接报错。
  • 把发送数减接收数当成“网络丢包”。它只能说明少收到了,不能定位链跳。
  • schema 改了但不升版本。新旧数据会在同一字段名下混在一起。
  • 只在客户端校验,不在入库侧复核。绕过客户端的脏数据仍会进入数仓。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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