8.2 埋点规范与口径统一

埋点口径打架的根因是事件没有 schema:用一份配置驱动的《王朝纪元》埋点规范(4 个事件、公共必填字段、命名正则、参数类型与枚举),配 TypeScript 校验器把一批上报样本判出 7 类违规码,让「口径统一」从口头约定变成可断言的埋点契约。

上一节我们把指标树 的每个叶子都挂上了一个 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 类违规码各命中一次——这不是巧合,而是样本里每一类都恰好埋了一条:

样本埋的坑判出的违规码
e2troop_count 写成了字符串 "120"TYPE_MISMATCH
e3事件名写成 MarchStart(驼峰)NAMING_VIOLATION + UNKNOWN_EVENT
e4出征事件少了 coord_yMISSING_REQUIRED
e5target_type 填了 dragon(不在枚举里)ENUM_INVALID
e6注册事件缺了公共字段 ts_msMISSING_COMMON
e7与 e1 共用了同一个 event_idDUP_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 断言。

下一节,我们把这些事件真正汇成时序,再看怎么把它们摆上看板、怎么用阈值与同比/环比规则告警。

阅读导航:上一节:北极星指标与指标树 · 下一节:数据看板与告警 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引