端上漏斗最常见的三个错
「回声」的端上漏斗曾经给出 12% 的支付转化率,服务端订单表只有 9%。产品先怀疑支付渠道,再怀疑埋点丢失,最后发现端上把三件不同的事算进了同一个分母:
- 曝光在组件渲染时上报,预渲染和折叠区域里的商品也被算作「看过」。
- 支付回跳就算支付成功,没有等待服务端确认。
- 用户换了一次会话,端上仍然把前后两段拼成一条旅程。
三个错误都指向同一件事:漏斗不是「按事件名计数」,而是先定义口径,再决定哪些事件可以进入端上计算。
步骤、会话、订单:三个口径必须先定
| 口径 | 要回答的问题 | 端上的默认做法 |
|---|---|---|
| 步骤 | 哪几个动作构成一条漏斗 | 曝光 → 点击 → 加购 → 提交 → 支付 |
| 会话 | 什么算同一次访问 | 同一 sessionId,跨会话不拼接 |
| 订单 | 订单步骤归属哪笔订单 | 同一会话只允许一个 orderId,多个则整段排除 |
| 窗口 | 两步之间最多隔多久 | 相邻步骤 30 分钟,超时断开 |
| 来源 | 哪些事件能进端上口径 | 只算 client,服务端事件单独计数 |
这张表里,订单口径最容易被低估。用户在同一会话里先下了一单未支付,又重新下了一单并支付,端上如果把两个 orderId 混在一起,就会得到一条不存在的「提交到支付」路径。正确的做法不是猜,而是把这段会话标记为「订单不唯一」,交给服务端按订单维度重算。
端上能算与不能算
端上漏斗的边界,不是「技术能不能拿到」,而是「这个结论由谁负责」。下面这张表是本节的核心:
| 指标 | 端上口径 | 说明 |
|---|---|---|
| 曝光 | 可算 | 只统计真正可见的曝光 |
| 点击 | 可算 | 按会话去重 |
| 加购 | 可算 | 与服务端购物车对账 |
| 发起提交 | 可算 | 只代表用户点了提交 |
| 提交成功 | 只能估算 | 必须等待服务端回执 |
| 支付成功 | 不能算 | 以渠道与服务端确认结果为准 |
| 退款 | 不能算 | 退款是服务端事实 |
| GMV | 不能算 | 金额必须由订单汇总 |
「提交成功只能估算」是最容易被写成事实的一项。端上收到 200 响应,不代表订单已经落库;服务端可能因为库存、风控、幂等冲突返回一个业务失败。端上可以展示「提交完成」,但在报表里必须标成估算,等订单接口确认后再转成事实。
一个可运行的端上漏斗
下面的模块属于 L1:输入是事件数组与配置,输出是每步会话数、转化率和排除原因。它不读系统时间、不发请求,所有时间都来自事件本身。
// file: src/analytics/funnel.ts
// tier: L1
// verify: node --test test/analytics/funnel.test.ts
export type FunnelEventName =
| "trade.product_impression"
| "trade.product_click"
| "trade.cart_add"
| "trade.order_submit"
| "trade.order_pay";
export type EventSource = "client" | "server";
export interface FunnelEvent {
eventId: string;
name: FunnelEventName;
sessionId: string;
orderId: string | null;
occurredAt: number;
source: EventSource;
}
export interface FunnelStep {
key: string;
eventName: FunnelEventName;
requireOrder: boolean;
}
export interface FunnelConfig {
steps: FunnelStep[];
windowMs: number;
}
export interface StepResult {
key: string;
sessions: number;
conversionFromPrev: number | null;
conversionFromStart: number;
}
export interface FunnelResult {
sessions: number;
steps: StepResult[];
excluded: {
serverTruth: number;
outsideWindow: number;
ambiguousOrder: number;
};
}
export const DEFAULT_STEPS: FunnelStep[] = [
{ key: "impression", eventName: "trade.product_impression", requireOrder: false },
{ key: "click", eventName: "trade.product_click", requireOrder: false },
{ key: "cart", eventName: "trade.cart_add", requireOrder: false },
{ key: "submit", eventName: "trade.order_submit", requireOrder: true },
{ key: "pay", eventName: "trade.order_pay", requireOrder: true },
];
export const DEFAULT_CONFIG: FunnelConfig = {
steps: DEFAULT_STEPS,
windowMs: 30 * 60_000,
};
function ratio(part: number, whole: number): number | null {
if (whole === 0) return null;
return Math.round((part / whole) * 10_000) / 10_000;
}
export function buildFunnel(
events: FunnelEvent[],
config: FunnelConfig = DEFAULT_CONFIG,
): FunnelResult {
if (config.steps.length === 0) throw new Error("漏斗至少需要一个步骤");
if (!Number.isInteger(config.windowMs) || config.windowMs <= 0) {
throw new Error("归因窗口必须是正整数毫秒");
}
const bySession = new Map<string, FunnelEvent[]>();
for (const event of events) {
const list = bySession.get(event.sessionId);
if (list === undefined) bySession.set(event.sessionId, [event]);
else list.push(event);
}
const counts = config.steps.map(() => 0);
const excluded = { serverTruth: 0, outsideWindow: 0, ambiguousOrder: 0 };
let sessions = 0;
for (const list of bySession.values()) {
const clientEvents = list.filter((event) => event.source === "client");
excluded.serverTruth += list.length - clientEvents.length;
if (clientEvents.length === 0) continue;
const orderIds = new Set(
clientEvents
.map((event) => event.orderId)
.filter((id): id is string => id !== null && id !== ""),
);
if (orderIds.size > 1) {
excluded.ambiguousOrder += 1;
continue;
}
const orderId = orderIds.size === 1 ? [...orderIds][0] : null;
const sorted = [...clientEvents].sort(
(a, b) => a.occurredAt - b.occurredAt || a.eventId.localeCompare(b.eventId),
);
let cursor = -Infinity;
let anchor: number | null = null;
let matched = 0;
for (let i = 0; i < config.steps.length; i += 1) {
const step = config.steps[i];
const candidate = sorted.find(
(event) =>
event.name === step.eventName &&
event.occurredAt >= cursor &&
(!step.requireOrder || (orderId !== null && event.orderId === orderId)),
);
if (candidate === undefined) break;
if (anchor !== null && candidate.occurredAt - anchor > config.windowMs) {
excluded.outsideWindow += 1;
break;
}
matched += 1;
counts[i] += 1;
cursor = candidate.occurredAt;
anchor = candidate.occurredAt;
}
if (matched > 0) sessions += 1;
}
const start = counts[0];
return {
sessions,
steps: config.steps.map((step, i) => ({
key: step.key,
sessions: counts[i],
conversionFromPrev: i === 0 ? null : ratio(counts[i], counts[i - 1]),
conversionFromStart: ratio(counts[i], start) ?? 0,
})),
excluded,
};
}
export type Metric =
| "impression"
| "click"
| "add_to_cart"
| "submit_started"
| "submit_succeeded"
| "paid"
| "refunded"
| "gmv";
export type ComputeTier = "client" | "client_estimate" | "server_truth";
export interface MetricBoundary {
tier: ComputeTier;
caveat: string;
}
const BOUNDARIES: Record<Metric, MetricBoundary> = {
impression: { tier: "client", caveat: "只统计可见曝光,避免预渲染撑大分母" },
click: { tier: "client", caveat: "端上直接记录,需按会话去重" },
add_to_cart: { tier: "client", caveat: "端上可算,但要与服务端购物车对账" },
submit_started: { tier: "client", caveat: "只代表发起提交,不代表下单成功" },
submit_succeeded: {
tier: "client_estimate",
caveat: "必须等待服务端回执,端上只能估算",
},
paid: { tier: "server_truth", caveat: "以支付渠道与服务端确认结果为准" },
refunded: { tier: "server_truth", caveat: "退款是服务端事实,端上不能自行判定" },
gmv: { tier: "server_truth", caveat: "金额口径必须由服务端订单汇总" },
};
export function metricBoundary(metric: Metric): MetricBoundary {
return { ...BOUNDARIES[metric] };
}
export function clientMayCompute(metric: Metric): boolean {
return BOUNDARIES[metric].tier === "client";
}
export function needsServerTruth(metric: Metric): boolean {
return BOUNDARIES[metric].tier === "server_truth";
}
算法有四个关键动作。
按 sessionId 分组。 不同会话的同名事件不会被拼在一起。即使用户是同一设备,端上也没有可靠身份把两次会话合并,交给服务端做身份归一更合适。
先把服务端事件挑出来。 source === "server" 的事件计入 excluded.serverTruth,但不进入端上漏斗。这不是丢弃,而是明确「这部分由另一套口径负责」。
订单号不唯一就整段排除。 同一会话出现两个及以上 orderId 时,端上无法判断哪个订单对应哪次支付,整段会话不进入漏斗,计入 ambiguousOrder。
顺序匹配加窗口。 每一步只找「时间不早于上一步」的第一个候选;相邻两步超过 windowMs 就断开,并计入 outsideWindow。没有候选不算超窗,只算正常掉队。
测试与真实输出
本机运行结果如下:
$ node --test test/analytics/funnel.test.ts
✔ 完整链路在默认五步漏斗中逐级计数 (1.114834ms)
✔ 同一会话的重复事件只计一次 (0.105334ms)
✔ 未走到下一步的会话在对应步骤掉队 (0.095083ms)
✔ 跨会话的事件不会被串成同一条漏斗 (0.090709ms)
✔ 同一会话出现多个订单号时整段排除 (0.071375ms)
✔ 订单步骤要求与前面步骤使用同一个订单号 (0.068959ms)
✔ 订单步骤遇到空订单号时不匹配 (0.074709ms)
✔ 超过归因窗口的下一步被断开 (0.070583ms)
✔ 刚好落在窗口边界内的事件仍可归因 (0.075125ms)
✔ 服务端事件不进入端上漏斗但单独计数 (0.114458ms)
✔ 没有候选下一步时不计入窗口外排除 (0.062875ms)
✔ 转化率同时给出环比与相对首步 (0.081917ms)
✔ 没有事件时所有计数为零且不除零 (0.054292ms)
✔ 空步骤与非法窗口被拒绝 (0.222542ms)
✔ 支付与退款属于服务端事实 (0.079834ms)
✔ 提交成功只是端上估算,不能当事实 (0.047958ms)
✔ 曝光、点击与加购可由端上直接计算 (0.040458ms)
✔ 默认漏斗只覆盖到支付,不把退款算作转化 (0.044625ms)
ℹ tests 18
ℹ suites 0
ℹ pass 18
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 170.310917
十八条用例覆盖:完整链路、重复事件、逐级掉队、跨会话、订单不唯一、同订单匹配、空订单号、超窗、窗口边界、服务端事件、无候选、两类转化率、空输入、非法配置、支付与退款边界、提交估算、端上可算指标、默认步骤。
一次把「服务端支付成功」算进端上漏斗的复盘
某交易客户端的漏斗配置里,支付步骤同时接受 trade.order_pay 和一条服务端回调事件。端上团队的理由很充分:回调事件也带 sessionId,看起来能拼上。
上线后支付转化率明显高于订单表。复盘发现两个问题:
- 服务端回调的
sessionId来自支付会话,与浏览会话不是同一个值,端上却按sessionId分组,导致一部分支付被算进了错误的浏览路径。 - 回调可能重复到达,端上没有按
eventId去重,同一笔支付被计了两次。
修复做了三件事:漏斗只接受 source === "client" 的事件;服务端事件单独进入对账报表;支付步骤改用「端上发起支付」而不是「服务端确认支付」,并在展示时明确标注这是端上口径。
回归后,端上漏斗与服务端订单表的差距回到可解释范围。团队留下一条约定:端上漏斗回答「用户走到了哪一步」,服务端报表回答「交易是否成立」,两者可以对照,不能互相替代。
端上漏斗与服务端报表如何对账
对账不是要求两个数字相等,而是要求差异能被解释。常见的三类差异:
- 时间差:端上在回跳时计数,服务端在回调时计数,中间可能隔几十秒。
- 身份差:端上按会话,服务端按用户或订单,跨设备行为只在服务端能合并。
- 事实差:端上把「提交成功」当估算,服务端可能因风控或库存拒绝。
健康的做法是给每个差异写一个预期区间和原因,而不是每次都对不上就改口径。口径一旦为了「对齐」而频繁调整,历史数据就失去了可比性。
小结
端上漏斗先定口径再计数:步骤、会话、订单、窗口、来源。只算客户端事件,同会话只允许一个订单号,相邻步骤受归因窗口约束,服务端事件单独计数。
buildFunnel 把这三个约束写成代码,metricBoundary 把「端上能算、只能估算、不能算」写成查表。支付、退款、GMV 永远属于服务端事实,端上只能展示,不能宣布。
下一节进入第四部分的收尾:把选品、下单、支付、退款串成一条端到端链路,检查前面各章的约束是否真的接得上。
阅读导航:上一节:12.2 上报队列、离线缓存与去重 · 下一节:13.1 端到端:选品、下单、支付、退款 。
常见错误与边界
- 曝光在渲染时上报,预渲染与折叠区域把漏斗分母撑大。
- 把支付回跳当成支付成功,端上漏斗与订单表长期对不上。
- 跨会话按设备号硬拼旅程,用户换设备后路径被错误合并。
- 同一会话多个订单号仍然计数,得到一条不存在的提交到支付路径。
- 归因窗口不设上限,三天前的曝光与今天的支付被算成一次转化。
- 服务端事件直接混入端上漏斗,同一笔支付被重复计数。
- 把「提交成功」当事实,忽略服务端业务失败与幂等冲突。
- 为了让端上数字对齐服务端而反复改口径,历史数据失去可比性。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。