报表上的安装,和链路上的安装不是一回事
买量后台说今天带来一批安装,游戏后台说今天新增另一批,归因平台给出第三个数。三个数字都「没错」,因为它们统计的是同一条链路的不同截面:广告平台知道自己被点了多少次,商店知道自己交付了多少次安装,游戏知道自己启动了多少个新账号,而只有把这四件事拼到同一个人身上,才能回答「这笔买量花得值不值」。
拼接失败时,链路不会报错。点击照常计费,安装照常发生,玩家照常进游戏,只是这条安装被记成了自然量。买量团队看到 CPI 变高,自然量团队看到占比变好,两边都基于错误的数字做决策。归因工程的第一目标不是「算得准」,而是「丢失可见」:每一条没有被归因的安装,都要能说清楚它丢在哪一段。
本节只处理链路的骨架:点击怎么落地、安装怎么拿到身份、激活怎么等待归因结果、事件怎么回传。窗口长度、多触点归属和隐私限制留到 7.2,回收与放量留到 7.3。
链路四段:点击、安装、激活、回传
把一次买量安装拆开,四个阶段各有自己的责任方和失败方式:
| 阶段 | 发生地 | 关键字段 | 典型失败 |
|---|---|---|---|
| 点击 | 广告平台与落地页 | click_id、channel、campaign_id、creative_id、ts、device_key | 落地页丢参、click_id 为空 |
| 安装 | 应用商店与安装器 | install_id、platform、installed_at、device_key、referrer | referrer 为空、重装被当成新增 |
| 激活 | 客户端首次启动 | install_id、first_open_at、账号 id | 激活早于归因结果、重复上报 |
| 回传 | 游戏服务端到广告平台 | event_name、value、currency、dedupe_key | 字段缺失、金额单位错、重复回传 |
四个阶段的顺序在日志里看起来很自然,在系统里却不是。点击发生在广告平台,安装发生在商店,激活发生在客户端,回传发生在服务端;没有任何一个系统同时看见四件事。归因要做的,是用有限的标识把四段日志连成一条记录。
这里有一个容易被忽略的时序问题:激活通常早于归因结果。玩家下载完立刻打开游戏,此时服务端可能还没收到点击记录,更没算完匹配。如果客户端在激活时直接向广告平台回传,它会因为拿不到 click_id 而放弃,或者更糟,回传一条没有归因键的事件。正确做法是客户端只把激活写入自己的事件队列,服务端等归因结果落定后再决定回传还是记为自然量。
身份拼接:click_id 优先,设备键兜底
拼接两个事件,靠的是它们共享的键。按可靠性从高到低:
| 方式 | 来源 | 可靠性 | 主要问题 |
|---|---|---|---|
| click_id 透传 | Android install referrer、iOS 剪贴板或 Universal Link | 高 | 用户关闭追踪、落地页丢参时拿不到 |
| 设备键精确相等 | IDFA/GAID 哈希或应用内生成的稳定 id | 中 | 隐私限制下可得率下降,跨设备无法覆盖 |
| 指纹近似 | IP、UA、机型、系统版本组合 | 低 | 误配与合规风险,只适合兜底 |
工程上建议按这个顺序逐级降级,并且每一级都要留下使用了哪一级的证据。只在结果里写 matched: true,事后无法判断这条归因是 click_id 命中的,还是指纹猜出来的;前者可以直接进结算,后者只能进观察报表。代码里的 Attribution 结构没有单独的「置信度」字段,而是用 status 区分 matched、organic、out-of-order、device-mismatch 和 reinstall,让每条记录都能回答「为什么是这个结果」。
精确相等是一个刻意保守的选择。click.deviceKey !== install.deviceKey 直接拒绝,不做编辑距离、不做模糊匹配。代价是覆盖率下降:同一个用户在点击后被系统重置了广告标识,或者点击发生在平板、安装发生在手机,这条安装就会掉到自然量。收益是结算口径干净:进入买量成本的每一条归因,都有可复核的强身份。模糊匹配应该作为独立通道运行,单独统计、单独定价,而不是悄悄混进主链路。
四类断点,四种账
归因丢失不是一种故障,而是四种不同的账:
- 点击没落地。落地页重定向丢了 query,或者渠道把 click_id 放在 URL fragment 里而页面没有解析。表现是点击有计费、安装无来源。处理方式是落地页只做参数透传,不依赖前端脚本拼接。
- referrer 为空。Android 安装器可能不返回 referrer,iOS 在用户拒绝追踪后也拿不到设备标识。这类安装应进入「待定」而不是直接记自然量,等 7.2 的聚合口径回补。
- 激活早到。客户端先上报激活,归因结果后到。若服务端把「先到」当成最终状态,这条安装永远无法回传。正确做法是激活事件带 install_id 入库,归因结果到达时再触发回传。
- 重装被当成新增。玩家卸载后重新安装,商店会再给一个 install_id。若不做重装识别,同一用户会被重复计入新增、重复回传安装事件,买量侧就会为同一个人付两次费。
四类断点里,只有第一类能在客户端修;后三类都必须由服务端的状态机兜住。这也是为什么归因不能只做成一个 SDK 埋点,它必须有一条服务端记录,能等待、能回补、能去重。
回传什么:事件、金额、币种、去重键
回传给广告平台的事件不需要很多,但每个字段都要有明确口径:
| 字段 | 口径 | 常见错误 |
|---|---|---|
| event_name | install、activate、purchase 三级别混用 | 把激活当安装回传,导致平台优化目标漂移 |
| value | 以分为单位的整数,退款记负数 | 元与分混用,金额放大 100 倍 |
| currency | 显式写 CNY 或 USD | 依赖平台默认币种,跨区结算对不上 |
| occurred_at | 事件发生时间,不是回传时间 | 用回传时间排序,迟到事件被排到未来 |
| dedupe_key | install_id 与 event_id 的组合 | 只用 event_id,两个安装的同名事件互相覆盖 |
回传是「至少一次」的投递。网络超时、平台限流、服务重启都会让同一条事件发两次。归因服务端不能假设平台一定去重,自己要先有一层幂等:同一个 install_id + event_id 只接受一次,值相同记为重复,值不同记为冲突并留证。7.2 会把这条规则扩展成完整的去重策略。
金额字段还有一个跨系统的坑:商店回调的金额、游戏内商品定价、广告平台回传的 value,三者可能使用不同币种和不同单位。归因层必须统一到「分」,并在记录里保留原始币种与汇率版本,否则月底对账时无法解释差异。
双案例:弱联网与强联网的链路差异
《潮汐消消》月新增 42 万,买量占 45%,约 18.9 万条安装需要点击归因;首包 120MB、弱联网,下载与首次启动之间可能隔几个小时,激活早到是常态,服务端必须把激活挂起等归因。
《潮汐方舟》月新增 8 万,买量占 65%,约 5.2 万条;首包 350MB、强联网,点击到安装的间隔被下载时长拉长,iOS 占 45%,设备标识可得率更低,拼接更依赖 7.2 的聚合口径。
把链路写成可运行的纯函数
两款产品的瓶颈并不在同一个位置:消消卡在「激活等归因」,方舟卡在「安装等下载」。前者要在服务端做事件挂起,后者要在点击记录里保留更长的有效期,两者都不能靠客户端本地缓存解决。这些判断需要一段可以逐分支断言的代码来固定。
下面的实现把链路拆成三个纯函数:解析点击、拼接安装、构造回传。它们不访问网络、不读写数据库,输入输出都是普通对象,因此每个分支都能被直接断言。
// file: src/biz/attribution.ts
// tier: L1
// verify: node --test test/biz/attribution.test.ts
export type ChannelId = "adnet-alpha" | "adnet-beta" | "adnet-gamma" | "organic";
export type Platform = "ios" | "android";
export type Currency = "CNY" | "USD";
export interface ClickRecord {
readonly clickId: string;
readonly channel: ChannelId;
readonly campaignId: string;
readonly creativeId: string;
readonly clickedAtMs: number;
readonly deviceKey: string;
readonly landing: string;
}
export interface InstallRecord {
readonly installId: string;
readonly platform: Platform;
readonly installedAtMs: number;
readonly deviceKey: string;
readonly referrer: string;
readonly reinstall: boolean;
}
export type ParseCode =
| "missing-click-id"
| "unknown-channel"
| "missing-campaign"
| "missing-device-key"
| "bad-timestamp";
export type ParseResult =
| { readonly ok: true; readonly click: ClickRecord }
| { readonly ok: false; readonly code: ParseCode; readonly detail: string };
const CHANNELS: readonly ChannelId[] = ["adnet-alpha", "adnet-beta", "adnet-gamma", "organic"];
function isChannel(value: string): value is ChannelId {
return (CHANNELS as readonly string[]).includes(value);
}
export function parseClick(raw: Record<string, string | undefined>): ParseResult {
const clickId = (raw.click_id ?? "").trim();
if (clickId === "") {
return { ok: false, code: "missing-click-id", detail: "click_id is required" };
}
const channelText = (raw.channel ?? "").trim().toLowerCase();
if (!isChannel(channelText)) {
return {
ok: false,
code: "unknown-channel",
detail: `channel "${channelText}" is not in the allowed list`,
};
}
const campaignId = (raw.campaign_id ?? "").trim();
if (campaignId === "") {
return { ok: false, code: "missing-campaign", detail: "campaign_id is required" };
}
const deviceKey = (raw.device_key ?? "").trim();
if (deviceKey === "") {
return { ok: false, code: "missing-device-key", detail: "device_key is required" };
}
const timestampText = (raw.ts ?? "").trim();
const clickedAtMs = Number(timestampText);
if (timestampText === "" || !Number.isInteger(clickedAtMs) || clickedAtMs <= 0) {
return {
ok: false,
code: "bad-timestamp",
detail: `ts "${timestampText}" must be a positive integer in milliseconds`,
};
}
return {
ok: true,
click: {
clickId,
channel: channelText,
campaignId,
creativeId: (raw.creative_id ?? "").trim(),
clickedAtMs,
deviceKey,
landing: (raw.landing ?? "").trim(),
},
};
}
export type AttributionStatus =
| "matched"
| "organic"
| "reinstall"
| "out-of-order"
| "device-mismatch";
export interface Attribution {
readonly status: AttributionStatus;
readonly channel: ChannelId;
readonly clickId: string | null;
readonly installId: string;
readonly detail: string;
}
export function attributeInstall(click: ClickRecord | null, install: InstallRecord): Attribution {
if (install.reinstall) {
return {
status: "reinstall",
channel: click?.channel ?? "organic",
clickId: click?.clickId ?? null,
installId: install.installId,
detail: "reinstall does not create a new install attribution",
};
}
if (click === null) {
return {
status: "organic",
channel: "organic",
clickId: null,
installId: install.installId,
detail: "no click record for this install",
};
}
if (click.clickedAtMs > install.installedAtMs) {
return {
status: "out-of-order",
channel: click.channel,
clickId: click.clickId,
installId: install.installId,
detail: "click happened after the install",
};
}
if (click.deviceKey !== install.deviceKey) {
return {
status: "device-mismatch",
channel: click.channel,
clickId: click.clickId,
installId: install.installId,
detail: "click and install device keys differ",
};
}
return {
status: "matched",
channel: click.channel,
clickId: click.clickId,
installId: install.installId,
detail: `matched via ${click.channel}/${click.campaignId}`,
};
}
export type EventName = "install" | "activate" | "purchase";
export interface EventRecord {
readonly eventId: string;
readonly eventName: EventName;
readonly valueCents: number;
readonly currency: Currency;
readonly occurredAtMs: number;
}
export interface Postback {
readonly channel: ChannelId;
readonly clickId: string;
readonly installId: string;
readonly eventName: EventName;
readonly valueCents: number;
readonly currency: Currency;
readonly occurredAtMs: number;
readonly dedupeKey: string;
}
export type PostbackCode = "not-attributed" | "missing-event-id" | "invalid-value";
export type PostbackResult =
| { readonly ok: true; readonly postback: Postback }
| { readonly ok: false; readonly code: PostbackCode; readonly detail: string };
export function buildPostback(attribution: Attribution, event: EventRecord): PostbackResult {
if (attribution.status !== "matched" || attribution.clickId === null) {
return {
ok: false,
code: "not-attributed",
detail: `${attribution.status} installs have no partner click to post back to`,
};
}
if (event.eventId.trim() === "") {
return { ok: false, code: "missing-event-id", detail: "event_id is required for dedupe" };
}
if (event.valueCents < 0 || (event.eventName === "purchase" && event.valueCents === 0)) {
return {
ok: false,
code: "invalid-value",
detail: `value ${event.valueCents} is not valid for ${event.eventName}`,
};
}
return {
ok: true,
postback: {
channel: attribution.channel,
clickId: attribution.clickId,
installId: attribution.installId,
eventName: event.eventName,
valueCents: event.valueCents,
currency: event.currency,
occurredAtMs: event.occurredAtMs,
dedupeKey: `${attribution.installId}:${event.eventId}`,
},
};
}
export function formatAttribution(attribution: Attribution): string {
const click = attribution.clickId ?? "-";
return `${attribution.status}:${attribution.channel} click=${click} install=${attribution.installId}`;
}
代码里的四种结局
parseClick 只做参数归一化与必填校验,返回 ok 或带 code 的拒绝。五个 code 分别对应五类脏数据:missing-click-id、unknown-channel、missing-campaign、missing-device-key、bad-timestamp。拒绝不抛异常,因为点击参数来自外部,脏数据是常态而不是异常。
attributeInstall 返回五种状态。matched 是唯一可以进入结算的状态;organic 表示没有点击记录;reinstall 表示这不是一次新安装;out-of-order 表示点击晚于安装;device-mismatch 表示两个设备键对不上。后四种都不是「错误」,而是链路必须如实记录的结局。
buildPostback 只接受 matched 且带 click_id 的归因。自然量没有可回传的广告平台,重装和顺序异常也不应该产生新的安装事件。它还会拒绝空 event_id 和不合法的金额:购买事件的 value 必须大于零,安装与激活可以为零。
formatAttribution 把结果压成一行稳定文本,用于日志和排查。它不用于玩家展示,也不用于结算;结算读的是结构化字段,不是这行字符串。
取舍:强身份换来的覆盖率损失
这套实现最重要的取舍,是宁可不归因,也不做模糊匹配。它牺牲了三类安装:设备标识被重置的、跨设备点击的、referrer 丢失但指纹可能对上的。按案例的量级,这些损失不会让报表归零,但足以让买量 ROI 被系统性低估。
补偿方式不是放宽主链路,而是增加旁路:
- 广告平台侧的聚合报表独立留存,用于校准总量,不参与单条归因。
- 指纹匹配只做观察通道,命中结果标记为低置信度,不进入买量成本。
- 对账任务按渠道比较「平台安装数」与「归因安装数」,差异超过阈值时告警,而不是自动补记。
代价是系统多了一套旁路和对账,收益是每一条进入结算的记录都能被复核。对按月投放百万级的团队,这个交换通常值得;对刚起步、量级很小的产品,旁路可以后置,但主链路的「拒绝要留痕」不能省。
小结
- 一次买量安装要穿过点击、安装、激活、回传四段,没有任何一个系统同时看见全部四段。
- 身份拼接按 click_id、设备键、指纹逐级降级,每一级都要留下证据,模糊匹配不能混进结算。
- 激活通常早于归因结果,服务端必须挂起激活事件,等归因落定后再决定回传或记自然量。
- 回传字段的口径要固定:事件名、以分为单位的金额、显式币种、install_id 与 event_id 组成的去重键。
- 强身份匹配会损失覆盖率,补偿应放在旁路报表和对账,而不是放宽主链路。
阅读导航:上一节:6.3 广告收入核算与 eCPM 口径 · 下一节:7.2 归因窗口、去重与隐私限制 。
常见错误与边界
- 把广告平台报表当成归因结果。平台只知道自己被点击和被回传了多少,不知道游戏内新增;直接用平台安装数结算会重复计算自然量。
- 落地页用前端脚本拼参数。脚本失败、重定向或用户禁用 JS 时 click_id 直接丢失;参数透传应由服务端或静态跳转完成。
- 激活时立即回传。此时归因结果通常还没到,回传会缺少 click_id;应先入库挂起,等归因落定再发。
- 把重装当新增。同一用户卸载重装会再次产生 install_id,重复计入新增并重复计费;必须识别重装并单独记账。
- 只用一个 event_id 去重。不同安装可能产生相同 event_id,键里必须带 install_id。
- 金额用元而不是分。浮点与单位混用会让回传金额放大或缩小 100 倍,跨币种时更难发现。
- 用回传时间代替事件时间。迟到事件会被排到错误的时间窗口,回收曲线随之失真。
- 模糊匹配直接进结算。指纹误配会把自然量算成买量,也会把买量算成自然量;只能作为观察通道。
- 归因失败不留原因。只记「未归因」无法区分丢参、重装和设备不匹配;必须保留 status 与拒绝 code。
- 认为归因只属于客户端。客户端只能采集,等待、回补、去重和结算都必须由服务端完成。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。