埋点事故很少是“没上报”
端上观测做久了会发现,最难的从来不是把事件发出去,而是保证发出去的事件能被正确解释。字段名从 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 改了但不升版本。新旧数据会在同一字段名下混在一起。
- 只在客户端校验,不在入库侧复核。绕过客户端的脏数据仍会进入数仓。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。