《移动游戏产品的架构·商业化与发行工程》10.1 商业化埋点体系与事件规范

埋点体系的价值不在事件数量,而在每个事件都能被校验、追溯和复用。本节以《潮汐消消》38 个商业化事件为背景,说明四类事件的边界、三段式命名、字段分层、版本兼容与 PII 红线,并给出一份可直接运行的注册表与校验器,让规范真正从文档落到代码。

埋点是契约,不是日志

日志的读者是开发者,埋点的读者是运营、财务、渠道和算法。同一个字段,开发者改个名字只需要同步一个仓库,埋点改个名字却会让昨天的报表、上周的归因和上个月的结算同时失真。所以商业化埋点的第一原则不是「多采」,而是「改名有代价、删除有流程、口径可追溯」。判断一个事件该不该进入商业化事件集,可以问三个问题:

  1. 谁会消费它?如果没有任何报表、结算、归因或实验会读它,它应该留在普通日志里。
  2. 它的字段含义稳定吗?如果一个字段的含义依赖页面位置或活动配置,它就不是一个合格的事件字段。
  3. 它变更时谁批准?如果没有 owner、没有评审、没有版本号,它迟早会变成没人敢动的历史包袱。《潮汐消消》与《潮汐方舟》共用同一套发行中台,但商业化埋点事件数分别是 38 个和 46 个。数量差异不是成熟度差异,而是交易链路长度差异。消消以混合变现为主,事件集中在广告位曝光、激励完成、内购下单与支付结果;方舟以内购为主、强联网、账号多端,订单状态、发货确认、退款与账号合并相关的事件更多。事件集的边界应该由业务链路决定,而不是由「别家有多少个事件」决定。

本节只讨论概念与规范,10.2 讨论如何用这些事件计算漏斗与留存,10.3 讨论当事件丢失、重复或口径漂移时如何发现和收口。

商业化事件只分四类

把商业化事件强行分成十几类,最后一定会有事件无处安放。四类足够覆盖绝大多数场景:

类别回答的问题典型事件主要消费方
exposure用户看到了什么商店条目曝光、广告位曝光、活动入口曝光运营、推荐、广告
click用户表达了什么意图商店条目点击、购买按钮点击、广告点击运营、归因、风控
transaction钱和订单发生了什么下单、支付成功、发货、退款财务、客服、对账
asset用户的资产如何变化代币消耗、道具发放、广告奖励到账经济系统、运营

四类之外的事件,例如页面停留、崩溃、帧率、网络错误,属于客户端可观测性或性能体系,不应该混进商业化事件集。混进来的直接后果是:商业化事件的丢失率被性能事件稀释,财务对账时无法判断「少的那部分」到底是支付失败还是采集失败。类别还决定了字段的最小集合。transaction 必须有订单号、商品号、金额和币种;asset 必须有资产类型、数量变化和来源;exposure 至少要能定位到位置和对象;click 要能关联到被点击的对象。没有这些字段的事件,即使名字起得再规范,也无法进入结算链路。

边界要写进规范,而不是靠口头约定。建议在注册表里给每个类别固定一组必填字段,新增事件只能在这组字段之上追加,不能减少。这样即使新人误建事件,校验器也会在接入阶段拦住。

命名:三段式加固定前缀

商业化事件统一使用下面的形式:

biz_<domain>_<object>_<action>

其中 domain 表示业务域,例如 store、order、ad、asset;object 表示被操作的对象,例如 item、order、reward;action 表示已经发生的事实,例如 expose、click、pay_success、reward_complete。

几条硬规则:

  • 全部小写,只用下划线分隔,不使用驼峰、连字符和中文。
  • 名字描述「已经发生的事」,不描述「准备做的事」。pay_success 是事实,pay_click 是意图,两者不能混用。
  • 版本号不进名字。biz_order_pay_success_v3 会让下游无法按名字聚合,版本应该放在事件字段里。
  • 渠道名、操作系统名、活动名不进名字。这些是维度,不是事件身份。
  • 一个名字只对应一种语义。如果同一个名字在不同页面有不同含义,说明缺少 slot 或 scene 字段,而不是需要第二个事件。
反例问题改法
BizStoreItemExpose驼峰、大小写混用biz_store_item_expose
biz_pay_success_v3版本进入名字biz_order_pay_success + version 字段
biz_ios_store_click平台进入名字biz_store_item_click + platform 元数据

命名规范看似形式主义,实际决定了三件事:下游能不能用一条正则筛出某业务域的全部事件;新人能不能只看名字就知道事件含义;重构时能不能安全地批量替换。三件事都依赖名字的机械可解析性。

字段分三层,禁止项单独列

一个合格的事件由三层字段组成:

  1. 元数据:user_id、session_id、platform、ts_ms、app_version、schema_version。这层由 SDK 统一填充,业务方不手写。
  2. 业务字段:由事件定义声明,分为必填与可选。金额一律用整数分,时间一律用毫秒时间戳,枚举值一律小写下划线。
  3. 禁止字段:手机号、邮箱、身份证号、精确经纬度、通讯录、剪贴板内容。这些字段不是「尽量不采」,而是「校验器直接拒绝」。

金额用整数分不是风格问题。浮点数在跨语言、跨数据库、跨报表工具的链路上会产生尾差,一笔 ¥0.01 的误差在 42 万笔月订单上就是几千元的对账缺口。币种必须与金额同时出现,否则不同地区的流水无法合并。时间用毫秒时间戳,而不是格式化字符串。字符串时区一旦丢失,跨时区的渠道账单就无法按天对齐;而毫秒时间戳可以在入库时统一转换,保留原始精度。

禁止字段要写进代码,而不是写进文档。文档会被跳过,校验器不会。下面的实现把 PII 字段名单硬编码在模块里,任何事件携带这些字段都会得到 pii-field,无论它是否在 schema 中声明过。这条规则的代价是偶尔误伤合法字段,但隐私事件的代价远高于改一个字段名。

版本化:加字段不等于改语义

事件版本解决的是「同一名字在不同时间含义不同」的问题。判断一次变更是否需要升版本,只看它是否改变了已有消费者的解释:

变更是否兼容是否升版本
新增可选字段兼容不需要,但建议记录
新增必填字段不兼容必须升版本
删除字段不兼容必须升版本
修改字段类型不兼容必须升版本
修改字段单位或枚举含义不兼容必须升版本
仅修改注释或 owner兼容不需要

最常见的错误是「只加了一个必填字段,觉得没什么」。对服务端来说确实只是多一列,但对已经发布的老版本客户端来说,它永远填不出这个字段,于是所有老版本事件都会变成脏数据。正确做法是:新字段先以可选身份上线,等老版本占比降到可接受范围,再在新版本中改为必填并升版本号。版本迁移需要双写期。旧版本和新版本同时上报一段时间,下游同时消费两套口径,确认新口径的报表与结算结果可以解释差异后,再下线旧版本。双写期的长度不由开发排期决定,而由客户端版本覆盖率决定;只要还有活跃用户停留在旧版本,旧事件就不能直接删除。

版本号放在事件字段里,还带来一个额外好处:同一条事件流可以按 schema_version 分组,快速看出哪个版本的字段缺失率异常。10.3 会把这种分组检查用于口径漂移的定位。

注册表与校验器

下面的实现把「规范」变成可执行代码:一份最小注册表、一个校验函数、一个归一化函数。它不依赖任何第三方库,可以直接放进客户端 SDK 的构建流程,也可以作为服务端入库前的最后一道门。

// file: src/biz/event-schema.ts
// tier: L1
// verify: node --test test/biz/event-schema.test.ts

export type EventCategory = "exposure" | "click" | "transaction" | "asset";
export type FieldType = "string" | "number" | "boolean";
export type Platform = "android" | "ios" | "taptap" | "web";
export type PropValue = string | number | boolean | null;
export type IssueCode =
  | "unknown-event"
  | "version-mismatch"
  | "bad-name"
  | "bad-meta"
  | "missing-required"
  | "unexpected-field"
  | "bad-type"
  | "pii-field";

export interface FieldSpec {
  readonly name: string;
  readonly type: FieldType;
  readonly required: boolean;
}
export interface EventDefinition {
  readonly name: string;
  readonly version: number;
  readonly category: EventCategory;
  readonly owner: string;
  readonly fields: readonly FieldSpec[];
}
export interface EventInput {
  readonly name: string;
  readonly version: number;
  readonly userId: string;
  readonly sessionId: string;
  readonly platform: Platform;
  readonly tsMs: number;
  readonly payload: Readonly<Record<string, PropValue>>;
}
export interface Issue {
  readonly code: IssueCode;
  readonly field: string;
  readonly detail: string;
}
export interface ValidationResult {
  readonly ok: boolean;
  readonly issues: readonly Issue[];
}
export interface NormalizedEvent {
  readonly eventId: string;
  readonly name: string;
  readonly version: number;
  readonly category: EventCategory;
  readonly userId: string;
  readonly sessionId: string;
  readonly platform: Platform;
  readonly tsMs: number;
  readonly props: Readonly<Record<string, PropValue>>;
}
export type NormalizeResult =
  | { readonly ok: true; readonly event: NormalizedEvent }
  | { readonly ok: false; readonly issues: readonly Issue[] };

const EVENT_NAME_PATTERN = /^biz_[a-z][a-z0-9]*(?:_[a-z0-9]+){2,4}$/;
const PLATFORMS: readonly Platform[] = ["android", "ios", "taptap", "web"];
const PII_FIELDS: readonly string[] = ["phone", "email", "id_card", "precise_lat", "precise_lng"];

function field(name: string, type: FieldType, required = false): FieldSpec {
  return { name, type, required };
}

export const MONETIZATION_CATALOG: readonly EventDefinition[] = [
  {
    name: "biz_store_item_expose",
    version: 2,
    category: "exposure",
    owner: "store",
    fields: [field("item_id", "string", true), field("slot", "string", true), field("currency", "string", true), field("price_cents", "number")],
  },
  {
    name: "biz_store_item_click",
    version: 2,
    category: "click",
    owner: "store",
    fields: [field("item_id", "string", true), field("slot", "string", true), field("price_cents", "number", true)],
  },
  {
    name: "biz_order_pay_success",
    version: 3,
    category: "transaction",
    owner: "payment",
    fields: [
      field("order_id", "string", true),
      field("product_id", "string", true),
      field("amount_cents", "number", true),
      field("currency", "string", true),
      field("channel", "string", true),
    ],
  },
  {
    name: "biz_ad_reward_complete",
    version: 2,
    category: "asset",
    owner: "ads",
    fields: [field("placement_id", "string", true), field("ecpm_cents", "number", true), field("reward_id", "string", true)],
  },
];

export function findEvent(catalog: readonly EventDefinition[], name: string): EventDefinition | null {
  for (const definition of catalog) {
    if (definition.name === name) {
      return definition;
    }
  }
  return null;
}

export function validateEvent(catalog: readonly EventDefinition[], input: EventInput): ValidationResult {
  const issues: Issue[] = [];
  if (!EVENT_NAME_PATTERN.test(input.name)) {
    issues.push({ code: "bad-name", field: "name", detail: `${input.name} does not follow biz_<domain>_<object>_<action>` });
  }
  if (input.userId.length === 0 || input.sessionId.length === 0) {
    issues.push({ code: "bad-meta", field: "userId", detail: "user and session ids are required" });
  }
  if (!Number.isFinite(input.tsMs) || input.tsMs <= 0) {
    issues.push({ code: "bad-meta", field: "tsMs", detail: `${input.tsMs} is not a valid timestamp` });
  }
  if (!PLATFORMS.includes(input.platform)) {
    issues.push({ code: "bad-meta", field: "platform", detail: `${input.platform} is not a known platform` });
  }

  const definition = findEvent(catalog, input.name);
  if (definition === null) {
    issues.push({ code: "unknown-event", field: "name", detail: `${input.name} is not registered` });
    return { ok: false, issues };
  }
  if (definition.version !== input.version) {
    issues.push({ code: "version-mismatch", field: "version", detail: `${input.name} expects v${definition.version}, got v${input.version}` });
  }

  const known = new Map<string, FieldSpec>();
  for (const spec of definition.fields) {
    known.set(spec.name, spec);
    const value = input.payload[spec.name];
    if (spec.required && (value === undefined || value === null)) {
      issues.push({ code: "missing-required", field: spec.name, detail: `${spec.name} is required by ${definition.name} v${definition.version}` });
    }
  }
  for (const key of Object.keys(input.payload)) {
    if (PII_FIELDS.includes(key)) {
      issues.push({ code: "pii-field", field: key, detail: `${key} must never enter the event stream` });
      continue;
    }
    const spec = known.get(key);
    if (spec === undefined) {
      issues.push({ code: "unexpected-field", field: key, detail: `${key} is not declared by the schema` });
      continue;
    }
    const value = input.payload[key];
    if (value !== undefined && value !== null && typeof value !== spec.type) {
      issues.push({ code: "bad-type", field: key, detail: `${key} expects ${spec.type}, got ${typeof value}` });
    }
  }
  return { ok: issues.length === 0, issues };
}

export function normalizeEvent(catalog: readonly EventDefinition[], input: EventInput, sequence: number): NormalizeResult {
  const check = validateEvent(catalog, input);
  if (!check.ok) {
    return { ok: false, issues: check.issues };
  }
  const definition = findEvent(catalog, input.name) as EventDefinition;
  const props: Record<string, PropValue> = {};
  for (const key of Object.keys(input.payload).sort()) {
    props[key] = input.payload[key] as PropValue;
  }
  return {
    ok: true,
    event: {
      eventId: eventId([input.name, input.version, input.userId, input.tsMs, sequence]),
      name: input.name,
      version: input.version,
      category: definition.category,
      userId: input.userId,
      sessionId: input.sessionId,
      platform: input.platform,
      tsMs: input.tsMs,
      props,
    },
  };
}

export function eventId(parts: readonly (string | number)[]): string {
  let hash = 0x811c9dc5;
  const text = parts.join("|");
  for (let index = 0; index < text.length; index += 1) {
    hash ^= text.charCodeAt(index);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return `evt_${hash.toString(16).padStart(8, "0")}`;
}

export function formatEvent(event: NormalizedEvent): string {
  const props = Object.keys(event.props).sort().map((key) => `${key}=${String(event.props[key])}`).join(" ");
  return `${event.eventId} ${event.name} v${event.version} user=${event.userId} platform=${event.platform} ${props}`;
}

代码里有几个刻意的设计。

第一,注册表只放四个代表性事件,不追求覆盖全部 38 个或 46 个。注册表是规范的最小可运行样本,真实项目应该由配置中心或代码生成维护完整清单。四个事件分别覆盖曝光、点击、交易、资产四类,足以验证类别与字段的约束。

第二,validateEvent 先校验元数据,再校验事件定义。元数据错误与事件错误分开报,接入方才能判断是 SDK 版本问题还是业务字段问题。unknown-event 会在字段检查之前返回,避免下游对着一个不存在的事件反复猜字段。

第三,必填字段的缺失同时接受「没有这个键」和「值为 null」两种形式。很多客户端会把空值序列化成 null,如果只检查 undefined,这类事件会漏过校验,最后在入库时变成空字符串。

第四,未声明字段直接拒绝,而不是静默保留。静默保留会让 schema 逐渐失去约束力,半年后没人知道某个字段是谁加的、还有没有人用。拒绝的代价是接入方需要先改注册表,但这正是我们想要的摩擦。

第五,事件 id 由事件名、版本、用户、时间和序号做 FNV-1a 哈希得到。它不依赖随机数,同一个事件在客户端重试时得到相同的 id,服务端可以用它做幂等。10.3 讨论重复率时,这个 id 是第一道去重依据。

第六,属性在归一化时按 key 排序。排序不改变语义,但让日志、快照和测试断言稳定;没有排序,同一份数据在不同运行里会产生不同的字符串,排查时无法直接 diff。

双案例:38 与 46 的差异

两个产品的商业化事件规模不同,但规范是同一套:

维度消消方舟对事件体系的影响
商业化埋点事件数38 个46 个方舟交易链路更长,事件更多
埋点丢失率2.5%(弱网)1.2%消消需要更强的补发与去重
事件重复率0.8%0.4%消消的重试更频繁,幂等键更关键
付费形态混合变现内购为主消消的广告事件占比更高
联网形态弱联网强联网消消的曝光类事件更容易丢
平台结构国内安卓 55%、iOS 30%iOS 45%、国内安卓 40%渠道字段与支付事件的分支不同

方舟的事件更多,不代表规范更复杂。多出来的事件落在订单状态与账号链路上:强联网让订单可实时确认,退款与发货可以拆成独立事件。消消的事件更少,但广告位多、弱网多,曝光与激励事件的丢失和重复压力更大。同一个中台、同一套校验器,两个产品只在注册表内容和必填字段上分叉。这样做的好处是修复一次、两边生效;代价是注册表需要支持按产品覆盖,而不能假设所有事件对所有产品都适用。

治理:谁可以新增一个事件

事件体系的腐化通常不是因为技术,而是因为流程。任何人可以随时加事件,半年后就会得到几百个语义重叠、没人维护的名字。建议把新增流程固定为四步:

  1. 提出:写明事件要回答的问题、消费方、字段、保留期和 owner。没有明确消费方的事件直接拒绝。
  2. 注册:在注册表中声明名字、类别、版本、必填与可选字段,跑一遍校验器。
  3. 双写:客户端与服务端同时上报,确认字段完整率和口径一致。
  4. 下线旧事件:确认没有下游依赖后,先停止上报,保留一个版本的查询窗口,再从注册表删除。

每一步都要有记录。注册表本身就是记录:谁加的、什么时候加的、属于哪个类别、当前版本是多少。没有注册表,事件治理只能靠代码搜索和口口相传。owner 不是形式。一个事件没有 owner,就没人对它的字段质量和口径负责;出问题时,数据团队只能猜。建议 owner 落到具体小组,而不是「数据部」这种无法追责的粒度。

取舍:严格校验会拖慢接入

严格校验的收益是可解释、可对账、可迁移;代价是接入变慢。客户端每加一个字段,都要先改注册表、再改映射、再跑校验;运营临时想看一个维度,不能直接让客户端「先传上来再说」。在活动节奏快的团队里,这套流程很容易被绕过,最后变成「注册表里没有,但线上已经在传」的灰色字段。

案例里的折中是「入口严格、出口分层」:

  • 客户端 SDK 只做结构校验与 PII 拦截,遇到未声明字段时丢弃并计数,不阻塞主流程。
  • 服务端入库前做完整校验,未声明字段进入隔离表,不进入正式事件流。
  • 隔离表按天汇总,超过阈值才告警,避免个别老版本客户端把告警打爆。
  • 任何字段从隔离表转正,都必须补注册表并走一次评审。

这个折中牺牲了「所有字段都实时可用」,换来了「主流程不被埋点拖垮」。埋点系统最怕的不是字段少,而是因为埋点故障影响支付和登录;把校验放在不影响主流程的位置,是必须付的代价。

小结

  • 埋点是跨团队契约,判断标准是消费方、字段稳定性和变更流程,而不是事件数量。
  • 商业化事件只分曝光、点击、交易、资产四类;性能与崩溃事件不进入商业化事件集。
  • 命名统一为 biz_<domain>_<object>_<action>,版本、渠道、平台都不进名字。
  • 字段分元数据、业务字段、禁止字段三层;金额用整数分,PII 由校验器硬拦截。
  • 新增必填、删除字段、改类型、改语义都必须升版本,并用双写期完成迁移。

阅读导航:上一节:9.3 活动结算与数据复盘 · 下一节:10.2 漏斗、留存与付费转化 。

常见错误与边界

  1. 把日志当埋点。日志可以随开发随手改,埋点不能;没有注册表和版本号,报表口径会在某次发版后悄悄变化。
  2. 事件名里带版本或渠道。下游无法按名字聚合,新增渠道就要复制一套事件;版本应放字段,渠道应放维度。
  3. 用同一个事件表达意图和事实。pay_click 与 pay_success 混在一起,转化率会被严重高估;意图和结果必须拆开。
  4. 金额用浮点数。跨语言和跨库的尾差会累积成对账缺口,42 万笔月订单上尤其明显;金额必须用整数分。
  5. 只检查 undefined 不检查 null。客户端把空值序列化成 null 时会漏过必填校验,入库后变成空字符串。
  6. 未声明字段静默保留。schema 会逐渐失去约束力,半年后没人知道字段来源;应拒绝或隔离并计数。
  7. 加必填字段不升版本。老版本客户端永远填不出新字段,整批事件变成脏数据;新字段应先可选、后必填。
  8. 把 PII 写进文档就算治理。文档会被跳过,只有校验器能保证手机号和身份证号不进入事件流。
  9. 事件没有 owner。字段质量、口径变更和下线都没人负责,出问题时只能靠猜;owner 必须落到具体小组。
  10. 为了活动速度绕过注册表。灰色字段一旦进入正式报表,清理成本远高于当初补一次评审;入口必须保留摩擦。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查