11.2 埋点规范与口径统一

同一个「活跃」,产品、运营、投放三份报表给出三个数——这就是口径不统一。本节把埋点拆成事件名、参数类型与口径字典三件套,给出一份可被程序消费的埋点规范,以及一段逐条比对的 S 层校验脚本:事件名是否已知、必填参数是否齐全、类型是否匹配、enum 取值是否合法,并交叉核对 11.1 指标树每个叶子是否都有口径。

上一节把指标树的每个叶子都挂到了一条 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 看板与告警 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制