上一节我们把指标树
的每个叶子都挂上了一个 source——那是采集用的埋点事件 id。但 id 只是一个名字,名字背后的事件到底长什么样,才是口径会不会打架的真正分水岭。这一节要做的,就是把那些 source 展开成一份可校验的埋点事件 schema。
先划边界。站内的经济系统运营 checklist 里有一条「统一数据口径」的条目,那是产业侧的原则;指标词典 定义的是指标(DAU、留存率怎么算)。本节两者都不重复,本节讲的是更靠下的一层:一个上报事件应该带哪些字段、字段是什么类型、命名怎么规范——也就是指标树最底下那根「接地线」怎么接。
口径打架,九成是事件没有 schema
「为什么我们的活跃数对不上?」——这类问题的根因,多半不在计算逻辑,而在同一个事件被两支团队写成了两个样子:客户端上报的 march_start 带 troop_count(数字),服务端补录的 march_start 却把 troop_count 写成了字符串;或者有人手滑写了个 MarchStart(驼峰),落进同一张表后就成了另一个事件。数据在采集那一刻就已经分叉了,后面怎么算都是错的。
要根治它,只有一条路:给每个事件一份 schema,并在上报入口就校验。下面这份配置是 D 层(静态层)产物:《王朝纪元》的埋点规范唯一真源。它规定了命名正则、公共必填字段、每个事件的必填/可选参数类型与枚举。
{
"version": 1,
"game": "王朝纪元",
"naming": {
"eventPattern": "^[a-z][a-z0-9]*(_[a-z0-9]+)+$",
"paramPattern": "^[a-z][a-z0-9]*(_[a-z0-9]+)*$",
"requiredCommon": ["event_id", "event_name", "player_id", "server_id", "ts_ms"]
},
"note": "埋点事件 schema 唯一真源:event_name 用小写下划线、至少两段;公共字段必填;参数类型与枚举在此声明。口径(caliber)逐事件唯一。",
"events": [
{
"name": "account_register_success",
"desc": "账号首次注册成功并进入主城",
"caliber": "客户端收到注册成功回包且已完成主城场景加载的那一次,同一账号仅上报首次",
"required": { "channel": "string" },
"optional": { "invite_code": "string" },
"enums": {}
},
{
"name": "march_start",
"desc": "领主发起一次出征",
"caliber": "点击出征按钮且服务端已扣减粮草、生成行军部队的那一次",
"required": { "target_type": "string", "troop_count": "int", "coord_x": "int", "coord_y": "int" },
"optional": { "alliance_id": "string", "terrain": "string" },
"enums": { "target_type": ["tile", "city", "resource", "monster"] }
},
{
"name": "building_upgrade",
"desc": "建筑升级成功",
"caliber": "升级动画播完、服务端回包确认等级已提升的那一次",
"required": { "building_id": "string", "from_level": "int", "to_level": "int" },
"optional": { "speedup_item": "string" },
"enums": {}
},
{
"name": "resource_collect",
"desc": "采集田地产出结算",
"caliber": "采集部队返回主城、资源入库成功的那一次结算",
"required": { "resource_id": "string", "amount": "int", "tile_id": "string" },
"optional": { "boost_pct": "int" },
"enums": { "resource_id": ["wood", "stone", "iron", "grain"] }
}
]
}
规范里有两类约束。第一类是命名:event_name 必须匹配 ^[a-z][a-z0-9]*(_[a-z0-9]+)+$(小写下划线、至少两段),参数名同理。第二类是结构:五个公共字段每个事件都必须有,且每个事件自己声明必填参数、可选参数和枚举。注意 caliber 字段——它是事件级的口径,和 8.1 里指标级的口径一样,要求逐事件唯一。
拿一批真实上报来验
规范写得再好,不跑一遍不知道它抓不抓得住脏数据。下面是一批待校验的上报样本(截取两条,完整 7 条见工作区),里面故意混进了各类违规。
{ "event_id": "e2", "event_name": "march_start", "player_id": "p1002", "server_id": "s1", "ts_ms": 1760000000500,
"target_type": "city", "troop_count": "120", "coord_x": 12, "coord_y": 7 },
{ "event_id": "e3", "event_name": "MarchStart", "player_id": "p1003", "server_id": "s1", "ts_ms": 1760000001000,
"target_type": "tile", "troop_count": 80, "coord_x": 5, "coord_y": 9 }
校验器要能对每条事件给出机器可读的违规码。我们固定 7 类:MISSING_COMMON(缺公共字段)、UNKNOWN_EVENT(事件未定义)、NAMING_VIOLATION(命名不合规)、MISSING_REQUIRED(缺必填参数)、TYPE_MISMATCH(参数类型不符)、ENUM_INVALID(枚举越界)、DUP_EVENT_ID(同一批里 event_id 重复)。下面这段是 P 层原型,用同一份 config/ 把样本逐条判一遍。
// file: proto/src/ch08_tracking.ts
// tier: P
// verify: node --test 'proto/**/*.test.ts'
// 埋点事件 schema 校验:读 config/ch08_tracking_spec.json 的事件定义,逐条校验
// config/ch08_events_sample.json 里的上报样本。违规码固定 7 类,可被断言。
// 零依赖,只用 node:fs / node:path。
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const HERE = dirname(fileURLToPath(import.meta.url));
export const CONFIG_DIR = join(HERE, "..", "..", "config");
export type ParamType = "string" | "int" | "float" | "bool";
export interface EventSpec {
name: string;
desc: string;
caliber: string;
required: Record<string, ParamType>;
optional: Record<string, ParamType>;
enums: Record<string, string[]>;
}
export interface SpecConfig {
version: number;
game: string;
naming: { eventPattern: string; paramPattern: string; requiredCommon: string[] };
events: EventSpec[];
}
export interface RawEvent {
event_id: string;
event_name: string;
player_id?: string;
server_id?: string;
ts_ms?: number;
[k: string]: unknown;
}
export type ViolationCode =
| "MISSING_COMMON" | "UNKNOWN_EVENT" | "NAMING_VIOLATION"
| "MISSING_REQUIRED" | "TYPE_MISMATCH" | "ENUM_INVALID" | "DUP_EVENT_ID";
export interface Violation { eventId: string; code: ViolationCode; field: string; detail: string }
export interface BatchReport {
totalEvents: number; violationCount: number; cleanEvents: number;
byCode: Record<string, number>; violations: Violation[];
}
export function loadJson<T>(name: string): T {
return JSON.parse(readFileSync(join(CONFIG_DIR, `${name}.json`), "utf-8")) as T;
}
function typeOk(v: unknown, t: ParamType): boolean {
switch (t) {
case "string": return typeof v === "string";
case "int": return typeof v === "number" && Number.isInteger(v);
case "float": return typeof v === "number";
case "bool": return typeof v === "boolean";
}
}
/** schema 自检:事件名唯一、命名合规、口径唯一且非空。返回错误列表(合法 spec 应为空)。 */
export function validateSpec(spec: SpecConfig): string[] {
const errs: string[] = [];
const evRe = new RegExp(spec.naming.eventPattern);
const pmRe = new RegExp(spec.naming.paramPattern);
const seenName = new Set<string>();
const seenCaliber = new Map<string, string>();
for (const e of spec.events) {
if (seenName.has(e.name)) errs.push(`DUP_EVENT_DEF ${e.name}`);
seenName.add(e.name);
if (!evRe.test(e.name)) errs.push(`BAD_EVENT_NAME ${e.name}`);
if (!e.caliber || !e.caliber.trim()) errs.push(`EMPTY_CALIBER ${e.name}`);
if (seenCaliber.has(e.caliber)) errs.push(`DUP_CALIBER ${e.name} -> ${seenCaliber.get(e.caliber)}`);
seenCaliber.set(e.caliber, e.name);
for (const p of [...Object.keys(e.required), ...Object.keys(e.optional)]) {
if (!pmRe.test(p)) errs.push(`BAD_PARAM_NAME ${e.name}.${p}`);
}
}
return errs;
}
export function validateBatch(spec: SpecConfig, events: RawEvent[]): BatchReport {
const evRe = new RegExp(spec.naming.eventPattern);
const byName = new Map<string, EventSpec>();
for (const e of spec.events) byName.set(e.name, e);
const seenId = new Set<string>();
const violations: Violation[] = [];
for (const ev of events) {
const id = String(ev.event_id ?? "<no-id>");
// 7) 事件 id 唯一
if (seenId.has(id)) violations.push({ eventId: id, code: "DUP_EVENT_ID", field: "event_id", detail: `重复的 event_id ${id}` });
seenId.add(id);
// 1) 公共字段必填
for (const c of spec.naming.requiredCommon) {
if (ev[c] === undefined) violations.push({ eventId: id, code: "MISSING_COMMON", field: c, detail: `缺公共字段 ${c}` });
}
// 3) 事件名命名规范
const name = String(ev.event_name ?? "");
if (!evRe.test(name)) violations.push({ eventId: id, code: "NAMING_VIOLATION", field: "event_name", detail: `事件名 ${name} 不符小写下划线规范` });
// 2) 事件必须已定义
const def = byName.get(name);
if (!def) {
violations.push({ eventId: id, code: "UNKNOWN_EVENT", field: "event_name", detail: `未在 spec 中定义的事件 ${name}` });
continue;
}
// 4) 必填参数存在且类型正确
for (const [p, t] of Object.entries(def.required)) {
if (ev[p] === undefined) {
violations.push({ eventId: id, code: "MISSING_REQUIRED", field: p, detail: `事件 ${name} 缺必填参数 ${p}` });
} else if (!typeOk(ev[p], t)) {
violations.push({ eventId: id, code: "TYPE_MISMATCH", field: p, detail: `${p} 期望 ${t},实为 ${typeof ev[p]}` });
}
}
// 5) 可选参数若出现则类型必须对
for (const [p, t] of Object.entries(def.optional)) {
if (ev[p] !== undefined && !typeOk(ev[p], t)) {
violations.push({ eventId: id, code: "TYPE_MISMATCH", field: p, detail: `${p} 期望 ${t},实为 ${typeof ev[p]}` });
}
}
// 6) 枚举取值合法
for (const [p, allowed] of Object.entries(def.enums)) {
const v = ev[p];
if (v !== undefined && !allowed.includes(String(v))) {
violations.push({ eventId: id, code: "ENUM_INVALID", field: p, detail: `${p}=${String(v)} 不在 ${allowed.join("/")}` });
}
}
}
const byCode: Record<string, number> = {};
for (const v of violations) byCode[v.code] = (byCode[v.code] ?? 0) + 1;
const badIds = new Set(violations.map((v) => v.eventId));
return {
totalEvents: events.length,
violationCount: violations.length,
cleanEvents: events.length - badIds.size,
byCode,
violations,
};
}
export function runReport(): { specErrors: string[]; batch: BatchReport } {
const spec = loadJson<SpecConfig>("ch08_tracking_spec");
const sample = loadJson<{ events: RawEvent[] }>("ch08_events_sample");
return { specErrors: validateSpec(spec), batch: validateBatch(spec, sample.events) };
}
if (import.meta.url === `file://${process.argv[1]}`) {
console.log(JSON.stringify(runReport(), null, 2));
}
跑 node proto/src/ch08_tracking.ts,它会先做 spec 自检(specErrors: []),再判这批样本。结果是这样的(同机相对量级,绝对数值不可移植):
specErrors: []
batch.totalEvents: 7 batch.violationCount: 7 batch.cleanEvents: 1
byCode: TYPE_MISMATCH 1 / NAMING_VIOLATION 1 / UNKNOWN_EVENT 1 / MISSING_REQUIRED 1 /
ENUM_INVALID 1 / MISSING_COMMON 1 / DUP_EVENT_ID 1
7 条样本判出 7 条违规,7 类违规码各命中一次——这不是巧合,而是样本里每一类都恰好埋了一条:
| 样本 | 埋的坑 | 判出的违规码 |
|---|---|---|
e2 | troop_count 写成了字符串 "120" | TYPE_MISMATCH |
e3 | 事件名写成 MarchStart(驼峰) | NAMING_VIOLATION + UNKNOWN_EVENT |
e4 | 出征事件少了 coord_y | MISSING_REQUIRED |
e5 | target_type 填了 dragon(不在枚举里) | ENUM_INVALID |
e6 | 注册事件缺了公共字段 ts_ms | MISSING_COMMON |
e7 | 与 e1 共用了同一个 event_id | DUP_EVENT_ID |
注意 e3 一条命中了两个码:命名不合规的事件,自然也不可能在 spec 里找到定义,所以 NAMING_VIOLATION 和 UNKNOWN_EVENT 会同时报——这是设计上的正确行为,命名闸门在前、定义闸门在后,两道都拦得住。
命名规范是口径的第一道闸
上面 7 类码里,最容易被低估的是 NAMING_VIOLATION。它看起来只是「大小写不统一」的小事,但它是口径分叉的第一现场:MarchStart、marchStart、march_start 在代码里是三个字符串,在数据仓库里就是三个事件。命名正则把它掐死在入口——^[a-z][a-z0-9]*(_[a-z0-9]+)+$ 要求小写、下划线、至少两段(register 这样的单段名会被拒,逼你写出 account_register_success 这种能自解释的名字)。
caliber 字段则管住了另一件事:事件级口径唯一。如果两个事件声明了同一句口径,validateSpec 会报 DUP_CALIBER——那说明它们要么该合并,要么有一个写错了。这和 8.1 里指标树的 DUPLICATE_CALIBER 是同一条规则,只是作用在事件层。
把它接进采集管线
这套校验器的正确用法不是事后跑,而是卡在上报入口:客户端 SDK 在发事件前先过一遍 validateBatch 的单条版本,服务端接收时再过一遍。任何 MISSING_COMMON / TYPE_MISMATCH / ENUM_INVALID 的上报,要么在客户端就被丢弃并记一条告警,要么进「脏数据隔离区」而不是主表。这样,指标树上的每个叶子(8.1 的 source)背后,就都有了一条类型与枚举都被钉死的采集链路——口径统一不再是靠开会约定,而是靠 schema 断言。
下一节,我们把这些事件真正汇成时序,再看怎么把它们摆上看板、怎么用阈值与同比/环比规则告警。
阅读导航:上一节:北极星指标与指标树 · 下一节:数据看板与告警 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。