埋点是契约,不是日志
日志的读者是开发者,埋点的读者是运营、财务、渠道和算法。同一个字段,开发者改个名字只需要同步一个仓库,埋点改个名字却会让昨天的报表、上周的归因和上个月的结算同时失真。所以商业化埋点的第一原则不是「多采」,而是「改名有代价、删除有流程、口径可追溯」。判断一个事件该不该进入商业化事件集,可以问三个问题:
- 谁会消费它?如果没有任何报表、结算、归因或实验会读它,它应该留在普通日志里。
- 它的字段含义稳定吗?如果一个字段的含义依赖页面位置或活动配置,它就不是一个合格的事件字段。
- 它变更时谁批准?如果没有 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 元数据 |
命名规范看似形式主义,实际决定了三件事:下游能不能用一条正则筛出某业务域的全部事件;新人能不能只看名字就知道事件含义;重构时能不能安全地批量替换。三件事都依赖名字的机械可解析性。
字段分三层,禁止项单独列
一个合格的事件由三层字段组成:
- 元数据:
user_id、session_id、platform、ts_ms、app_version、schema_version。这层由 SDK 统一填充,业务方不手写。 - 业务字段:由事件定义声明,分为必填与可选。金额一律用整数分,时间一律用毫秒时间戳,枚举值一律小写下划线。
- 禁止字段:手机号、邮箱、身份证号、精确经纬度、通讯录、剪贴板内容。这些字段不是「尽量不采」,而是「校验器直接拒绝」。
金额用整数分不是风格问题。浮点数在跨语言、跨数据库、跨报表工具的链路上会产生尾差,一笔 ¥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% | 渠道字段与支付事件的分支不同 |
方舟的事件更多,不代表规范更复杂。多出来的事件落在订单状态与账号链路上:强联网让订单可实时确认,退款与发货可以拆成独立事件。消消的事件更少,但广告位多、弱网多,曝光与激励事件的丢失和重复压力更大。同一个中台、同一套校验器,两个产品只在注册表内容和必填字段上分叉。这样做的好处是修复一次、两边生效;代价是注册表需要支持按产品覆盖,而不能假设所有事件对所有产品都适用。
治理:谁可以新增一个事件
事件体系的腐化通常不是因为技术,而是因为流程。任何人可以随时加事件,半年后就会得到几百个语义重叠、没人维护的名字。建议把新增流程固定为四步:
- 提出:写明事件要回答的问题、消费方、字段、保留期和 owner。没有明确消费方的事件直接拒绝。
- 注册:在注册表中声明名字、类别、版本、必填与可选字段,跑一遍校验器。
- 双写:客户端与服务端同时上报,确认字段完整率和口径一致。
- 下线旧事件:确认没有下游依赖后,先停止上报,保留一个版本的查询窗口,再从注册表删除。
每一步都要有记录。注册表本身就是记录:谁加的、什么时候加的、属于哪个类别、当前版本是多少。没有注册表,事件治理只能靠代码搜索和口口相传。owner 不是形式。一个事件没有 owner,就没人对它的字段质量和口径负责;出问题时,数据团队只能猜。建议 owner 落到具体小组,而不是「数据部」这种无法追责的粒度。
取舍:严格校验会拖慢接入
严格校验的收益是可解释、可对账、可迁移;代价是接入变慢。客户端每加一个字段,都要先改注册表、再改映射、再跑校验;运营临时想看一个维度,不能直接让客户端「先传上来再说」。在活动节奏快的团队里,这套流程很容易被绕过,最后变成「注册表里没有,但线上已经在传」的灰色字段。
案例里的折中是「入口严格、出口分层」:
- 客户端 SDK 只做结构校验与 PII 拦截,遇到未声明字段时丢弃并计数,不阻塞主流程。
- 服务端入库前做完整校验,未声明字段进入隔离表,不进入正式事件流。
- 隔离表按天汇总,超过阈值才告警,避免个别老版本客户端把告警打爆。
- 任何字段从隔离表转正,都必须补注册表并走一次评审。
这个折中牺牲了「所有字段都实时可用」,换来了「主流程不被埋点拖垮」。埋点系统最怕的不是字段少,而是因为埋点故障影响支付和登录;把校验放在不影响主流程的位置,是必须付的代价。
小结
- 埋点是跨团队契约,判断标准是消费方、字段稳定性和变更流程,而不是事件数量。
- 商业化事件只分曝光、点击、交易、资产四类;性能与崩溃事件不进入商业化事件集。
- 命名统一为
biz_<domain>_<object>_<action>,版本、渠道、平台都不进名字。 - 字段分元数据、业务字段、禁止字段三层;金额用整数分,PII 由校验器硬拦截。
- 新增必填、删除字段、改类型、改语义都必须升版本,并用双写期完成迁移。
阅读导航:上一节:9.3 活动结算与数据复盘 · 下一节:10.2 漏斗、留存与付费转化 。
常见错误与边界
- 把日志当埋点。日志可以随开发随手改,埋点不能;没有注册表和版本号,报表口径会在某次发版后悄悄变化。
- 事件名里带版本或渠道。下游无法按名字聚合,新增渠道就要复制一套事件;版本应放字段,渠道应放维度。
- 用同一个事件表达意图和事实。
pay_click与pay_success混在一起,转化率会被严重高估;意图和结果必须拆开。 - 金额用浮点数。跨语言和跨库的尾差会累积成对账缺口,42 万笔月订单上尤其明显;金额必须用整数分。
- 只检查
undefined不检查null。客户端把空值序列化成null时会漏过必填校验,入库后变成空字符串。 - 未声明字段静默保留。schema 会逐渐失去约束力,半年后没人知道字段来源;应拒绝或隔离并计数。
- 加必填字段不升版本。老版本客户端永远填不出新字段,整批事件变成脏数据;新字段应先可选、后必填。
- 把 PII 写进文档就算治理。文档会被跳过,只有校验器能保证手机号和身份证号不进入事件流。
- 事件没有 owner。字段质量、口径变更和下线都没人负责,出问题时只能靠猜;owner 必须落到具体小组。
- 为了活动速度绕过注册表。灰色字段一旦进入正式报表,清理成本远高于当初补一次评审;入口必须保留摩擦。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。