上一节把指标树的每个叶子都挂到了一条 event(埋点事件)上。本节要回答的是:这条 event 到底该长什么样。指标树解决了「要算哪些数」,埋点规范解决「这些数从哪条事件、哪个字段算出来」。
先说边界。埋点规范管的是「数据怎么记」;「记下来之后怎么算留存漏斗」是第 7 章的事,「怎么用 A/B 验证改动」是第 8 章的事。本节不碰它们,只保证一件事:记下来的数据是干净的、口径是唯一的——否则后面漏斗和实验都是在脏数据上做文章。
口径:同一个词,必须同一个定义
数据体系里最贵的错误不是算错,是口径不统一。产品看板上的「活跃」是「打开过小游戏」;运营报表里的「活跃」是「完成过一局」;投放后台的「活跃」是「有任意一次前台事件」。三个「活跃」三个数,开会时三个人各拿一个数吵架,谁也说服不了谁。这不是数学问题,是定义问题——而定义问题必须在埋点层就解决,不能等到看板上再解释。
口径要统一,靠的不是「大家记着点」,是一份口径字典:每个指标名对应唯一一条定义,写清楚统计窗口(自然日还是滚动 7 日)、去重口径(去重用户还是次数)、责任人(谁对这条口径负责)。字典里同一个指标名出现两次,就是 bug——校验器会直接报错。
埋点规范的三件套
一条埋点事件由三样东西定义清楚:
- 事件名:用动词 + 名词的英文 snake_case,比如
session_start、ad_impression、iap_purchase。全站唯一,不得重名。 - 参数:每个参数声明类型(
int/string/bool)、是否必填、以及(可选)enum 取值域。类型错了比缺参数更隐蔽——level: "three"这种字符串混进本该是整数的字段,下游求和会静默出错。 - 触发时机:一句话写清「什么时候上报」。这决定了事件的语义,也决定了它能不能支撑某条口径。
埋点规范的价值在于它是可校验的:有了事件名、参数类型、enum 域,一条真实上报就能被机器逐字段比对,不用靠人肉眼查。
D 层:把规范写成配置
下面这份是 D 层(静态层)配置,三部分:calibers 是口径字典(13 个指标,与 11.1 指标树一一对应),events 是 10 条事件的规范,sampleLog 是一段 14 条真实上报的样例流,供校验器逐条比对。
{
"version": 1,
"series": "《小游戏开发实战》",
"volume": "卷三《上线变现与长线运营》",
"game": "merge-island",
"observedAt": "2026-10",
"note": "埋点规范与口径字典(D 层)。calibers 是口径字典:每个指标名对应唯一定义、统计窗口与责任人;events 是事件规范:事件名 + 参数(类型 int/string/bool、required、enum 取值域);sampleLog 是一段样例事件流,供校验器逐条比对。校验器还会交叉核对 11.1 的指标树:每个叶子指标的 event 必须在本规范中存在,且每个指标都必须有口径。全整数,禁浮点。",
"units": {
"window": "口径统计窗口",
"required": "该参数是否必填"
},
"calibers": [
{ "metric": "efficient-active", "definition": "有效活跃(北极星):当日登录且完成至少 1 次合成的去重用户数", "window": "自然日 00:00–24:00", "owner": "制作人" },
{ "metric": "d1-retention", "definition": "次日留存:当日新增用户中,次日在线的去重用户占比", "window": "T+1 自然日", "owner": "数据分析" },
{ "metric": "d7-retention", "definition": "7 日留存:当日新增用户中,第 7 日在线的去重用户占比", "window": "T+7 自然日", "owner": "数据分析" },
{ "metric": "session-per-dau", "definition": "人均日启动:当日 session_start 次数 / DAU", "window": "自然日", "owner": "数据分析" },
{ "metric": "arpdau", "definition": "ARPDAU:当日广告与内购收入(分)/ DAU", "window": "自然日", "owner": "商业化" },
{ "metric": "pay-rate", "definition": "付费率:当日有成功内购的去重用户 / DAU", "window": "自然日", "owner": "商业化" },
{ "metric": "ad-imp-per-dau", "definition": "人均广告展示:当日 ad_impression 次数 / DAU", "window": "自然日", "owner": "商业化" },
{ "metric": "organic-k", "definition": "自然裂变 K 值:每个用户平均带来的新增邀请转化数 ×1000", "window": "近 7 日滚动", "owner": "增长" },
{ "metric": "ua-roas", "definition": "买量 ROAS:渠道归因用户首日收入 / 买量花费", "window": "归因后 T+1", "owner": "增长" },
{ "metric": "invite-per-user", "definition": "人均邀请:当日 invite_sent 次数 / DAU ×1000", "window": "自然日", "owner": "增长" },
{ "metric": "crash-free-rate", "definition": "无崩溃会话率:无 crash 标记的 session_end / 全部 session_end ×1000", "window": "自然日", "owner": "客户端" },
{ "metric": "first-load-sec", "definition": "首包加载耗时:first_frame 时间戳 − 启动时间戳(秒)", "window": "自然日 P50", "owner": "客户端" },
{ "metric": "tutorial-complete", "definition": "引导完成率:tutorial_complete 去重用户 / 当日新增用户", "window": "T+1 自然日", "owner": "策划" }
],
"events": [
{ "name": "session_start", "trigger": "小游戏前台进入", "params": [
{ "name": "scene", "type": "string", "required": true, "enum": ["home", "level", "shop"] },
{ "name": "ts", "type": "int", "required": true } ] },
{ "name": "session_end", "trigger": "退到后台或退出", "params": [
{ "name": "durationSec", "type": "int", "required": true },
{ "name": "crashed", "type": "bool", "required": false } ] },
{ "name": "first_frame", "trigger": "首帧渲染完成", "params": [
{ "name": "loadMs", "type": "int", "required": true } ] },
{ "name": "merge", "trigger": "完成一次合成", "params": [
{ "name": "level", "type": "int", "required": true },
{ "name": "count", "type": "int", "required": true } ] },
{ "name": "ad_request", "trigger": "发起广告请求", "params": [
{ "name": "format", "type": "string", "required": true, "enum": ["rewarded", "interstitial", "banner", "native"] } ] },
{ "name": "ad_impression", "trigger": "广告成功展示", "params": [
{ "name": "format", "type": "string", "required": true, "enum": ["rewarded", "interstitial", "banner", "native"] },
{ "name": "revenueFen", "type": "int", "required": true } ] },
{ "name": "iap_purchase", "trigger": "内购支付成功", "params": [
{ "name": "sku", "type": "string", "required": true },
{ "name": "amountFen", "type": "int", "required": true } ] },
{ "name": "invite_sent", "trigger": "发起一次好友邀请", "params": [
{ "name": "channel", "type": "string", "required": true, "enum": ["friend", "group", "poster"] } ] },
{ "name": "attribution_install", "trigger": "归因安装上报", "params": [
{ "name": "channel", "type": "string", "required": true },
{ "name": "costFen", "type": "int", "required": true } ] },
{ "name": "tutorial_complete", "trigger": "完成新手引导", "params": [
{ "name": "step", "type": "int", "required": true } ] }
],
"sampleLog": [
{ "name": "session_start", "params": { "scene": "home", "ts": 1000 } },
{ "name": "first_frame", "params": { "loadMs": 2400 } },
{ "name": "merge", "params": { "level": 3, "count": 2 } },
{ "name": "session_start", "params": { "ts": 1300 } },
{ "name": "ad_impression", "params": { "format": "rewarded", "revenueFen": 26 } },
{ "name": "ad_impression", "params": { "format": "splash", "revenueFen": 12 } },
{ "name": "iap_purchase", "params": { "sku": "pass_month", "amountFen": 1800 } },
{ "name": "iap_purchase", "params": { "sku": "pass_month" } },
{ "name": "invite_sent", "params": { "channel": "friend" } },
{ "name": "level_up", "params": { "level": 4 } },
{ "name": "session_end", "params": { "durationSec": 320, "crashed": false } },
{ "name": "merge", "params": { "level": "three", "count": 1 } },
{ "name": "tutorial_complete", "params": { "step": 8 } },
{ "name": "attribution_install", "params": { "channel": "douyin", "costFen": 120 } }
]
}
S 层:schema 校验脚本
下面这段是 S 层校验脚本,做四件事:查口径完整性(指标树每个叶子与北极星都有唯一定义)、事件规范自检(名字唯一、类型合法、required 是布尔)、交叉核对(叶子的 event 都存在)、以及逐条比对样例流。产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/l11_tracking_spec.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l11_tracking_spec.py --config config/ --out out/
"""埋点口径 schema 校验:读 config/l11_tracking_spec.json + config/l11_metric_tree.json。
1) 口径完整性——指标树每个叶子与北极星都必须在 calibers 里有唯一定义(无重复、定义非空)。
2) 事件规范自检——事件名唯一;参数 type ∈ {int,string,bool};required 为布尔。
3) 交叉核对——指标树叶子引用的 event 必须在本规范的 events 里存在。
4) 样例流比对——逐条检查 sampleLog:事件名已知?必填参数齐全?类型匹配?enum 取值合法?
产出:out/l11_tracking_spec.json + .csv + .svg。全整数,禁浮点。样例流为示例数据,真实埋点需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
TYPE_OK = {"int", "string", "bool"}
KIND_COLOR = {"unknownEvent": "#dc2626", "missingParam": "#f59e0b", "typeMismatch": "#7c3aed",
"enumViolation": "#2563eb", "unknownParam": "#6b7280"}
def load(path: str) -> dict:
with open(path, encoding="utf-8") as f:
return json.load(f)
def py_type(v) -> str:
return "bool" if isinstance(v, bool) else "int" if isinstance(v, int) else "string" if isinstance(v, str) else "other"
def check_calibers(spec: dict, tree: dict) -> list[str]:
errs, seen = [], {}
for c in spec["calibers"]:
seen[c["metric"]] = seen.get(c["metric"], 0) + 1
if not c.get("definition"):
errs.append(f"口径 {c['metric']} 定义为空")
for m, n in seen.items():
if n > 1:
errs.append(f"口径 {m} 重复 {n} 次")
leaves = {n["id"] for n in tree["nodes"] if not any(c["parent"] == n["id"] for c in tree["nodes"])}
for m in sorted((leaves | {tree["northStar"]}) - set(seen)):
errs.append(f"指标 {m} 缺口径定义")
return errs
def check_events(spec: dict) -> list[str]:
errs, names = [], [e["name"] for e in spec["events"]]
for n in set(names):
if names.count(n) > 1:
errs.append(f"事件 {n} 重名")
for e in spec["events"]:
for p in e["params"]:
if p["type"] not in TYPE_OK:
errs.append(f"{e['name']}.{p['name']}: type={p['type']} 非法")
if not isinstance(p["required"], bool):
errs.append(f"{e['name']}.{p['name']}: required 非布尔")
return errs
def check_cross(spec: dict, tree: dict) -> list[str]:
names = {e["name"] for e in spec["events"]}
return [f"指标 {n['id']} 引用事件 {n['event']} 未在规范中定义"
for n in tree["nodes"] if n.get("event") and n["event"] not in names]
def check_log(spec: dict) -> list[dict]:
by_name = {e["name"]: e for e in spec["events"]}
results = []
for i, rec in enumerate(spec["sampleLog"]):
ev = by_name.get(rec["name"])
if ev is None:
results.append({"index": i, "event": rec["name"], "ok": False,
"kinds": ["unknownEvent"], "details": [f"未知事件 {rec['name']}"]})
continue
declared = {p["name"]: p for p in ev["params"]}
vios = []
for pname, p in declared.items():
if p["required"] and pname not in rec["params"]:
vios.append(f"missingParam:{pname}")
elif pname in rec["params"]:
got = py_type(rec["params"][pname])
if got != p["type"]:
vios.append(f"typeMismatch:{pname}={got}!={p['type']}")
elif "enum" in p and rec["params"][pname] not in p["enum"]:
vios.append(f"enumViolation:{pname}={rec['params'][pname]}")
vios += [f"unknownParam:{p}" for p in rec["params"] if p not in declared]
results.append({"index": i, "event": rec["name"], "ok": not vios,
"kinds": sorted({v.split(":")[0] for v in vios}), "details": vios})
return results
def draw_svg(path: str, by_kind: dict) -> int:
W, PAD, ROW = 720, 190, 36
rows = [(k, by_kind.get(k, 0)) for k in KIND_COLOR]
H = 66 + ROW * len(rows)
peak = max((v for _, v in rows), default=1) or 1
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = \
"《合成小岛》埋点校验:样例流违规按类型分布"
for i, (k, v) in enumerate(rows):
y = 46 + i * ROW
ET.SubElement(svg, "text", {"x": "16", "y": str(y + 16), "font-size": "12", "fill": "#374151"}).text = k
w = (W - PAD - 60) * v // peak
ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 4), "width": str(max(w, 2)),
"height": "18", "rx": "3", "fill": KIND_COLOR[k]})
ET.SubElement(svg, "text", {"x": str(PAD + max(w, 2) + 8), "y": str(y + 17),
"font-size": "12", "fill": "#6b7280"}).text = str(v)
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return os.path.getsize(path)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
spec = load(os.path.join(a.config, "l11_tracking_spec.json"))
tree = load(os.path.join(a.config, "l11_metric_tree.json"))
spec_errs = check_calibers(spec, tree) + check_events(spec) + check_cross(spec, tree)
log = check_log(spec)
bad = [r for r in log if not r["ok"]]
by_kind: dict[str, int] = {}
for r in bad:
for k in r["kinds"]:
by_kind[k] = by_kind.get(k, 0) + 1
os.makedirs(a.out, exist_ok=True)
sb = draw_svg(os.path.join(a.out, "l11_tracking_spec.svg"), by_kind)
with open(os.path.join(a.out, "l11_tracking_log.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["index", "event", "ok", "kinds", "details"])
for r in log:
w.writerow([r["index"], r["event"], "1" if r["ok"] else "0", "|".join(r["kinds"]), "|".join(r["details"])])
report = {"version": spec["version"], "game": spec["game"], "observedAt": spec["observedAt"],
"caliberCount": len(spec["calibers"]), "eventCount": len(spec["events"]),
"logCount": len(log), "violatingRecords": len(bad), "byKind": by_kind,
"specViolations": spec_errs, "valid": not spec_errs and not bad, "svgBytes": sb}
with open(os.path.join(a.out, "l11_tracking_spec.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({"caliberCount": report["caliberCount"], "eventCount": report["eventCount"],
"logCount": report["logCount"], "violatingRecords": len(bad), "byKind": by_kind,
"specViolations": spec_errs, "svgBytes": sb}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它(示例数据),输出如下:
{
"caliberCount": 13,
"eventCount": 10,
"logCount": 14,
"violatingRecords": 5,
"byKind": {
"missingParam": 2,
"enumViolation": 1,
"unknownEvent": 1,
"typeMismatch": 1
},
"specViolations": [],
"svgBytes": 1293
}
specViolations 为空,说明规范本身自洽——13 个指标全有口径、10 条事件无重名、指标树引用的 8 条事件都在规范里。但样例流里 14 条有 5 条不合格,每类各露一面:第 3 条 session_start 缺必填的 scene(missingParam),第 5 条 ad_impression 的 format 报了 splash——不在 enum 域里(enumViolation),第 7 条 iap_purchase 缺 amountFen(missingParam),第 9 条 level_up 是规范里根本没有的事件(unknownEvent),第 11 条 merge 的 level 是字符串 "three" 而非整数(typeMismatch)。五种错误里有三种是「静默」的——缺参数、enum 越界、类型错误,如果不在埋点层拦下来,它们会一路流进看板,变成「那个数怎么对不上」的玄学。
小结与下一步
这一节把埋点做成了一份可校验的规范:口径字典终结「同一个词不同定义」,事件规范钉死名字、类型与 enum,样例流校验把脏数据挡在上游。三件事记住就够——口径必须有唯一定义并落到字典;事件名唯一、参数带类型与必填;脏数据要在这层拦,不能等看板出事再查。
到这里,指标树(11.1)和埋点规范(11.2)都有了。下一节把它们接起来:指标怎么摆上看板、告警阈值怎么定、以及一个更关键的问题——告警到底是漏报更糟,还是误报更糟。
阅读导航:上一节:指标树与北极星指标 · 下一节:11.3 看板与告警 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。