《互联网产品的客户端架构·交易、增长与平台化》12.1 埋点模型与事件契约

交易埋点最容易在「同名事件两个口径」上失真。本节把曝光、点击、加购、下单、支付、退款六类事件写成带 schema 版本的契约,用必填字段、命名规范与 orderId/traceId 串联把端上采集约束住,并给出可运行的校验、链路串联与 schema 差异检测模块。

一次对不上的支付成功率

「回声」大促后的复盘会上,两个团队拿出了两个支付成功率:客户端看板 71%,服务端订单表 64%。差 7 个百分点,足够让一场实验的结论反转。

  • 客户端把「从支付渠道回跳」当成支付成功,但回跳不等于扣款成功,用户可能只是点了返回。
  • 同一个支付动作被上报了两次:按钮点击一次,回跳页面一次,eventId 每次重新生成,服务端无法去重。
  • 两个端用了不同的字段名,一个叫 order_id,一个叫 orderId,同一个订单在报表里变成了两条。

这三类问题都不是「采集得不够多」,而是事件没有契约:名字、字段、版本、串联键、来源,五项里缺一项,后面的清洗与对账就要靠人猜。

事件契约先固定五件事

要素要固定什么不固定会怎样
名称命名空间 + 下划线动作,如 trade.order_pay同名不同义,报表无法合并
字段哪些必填、类型、枚举范围缺字段的行被静默丢弃或补默认值
版本每条事件带 schemaVersion新旧端字段语义不同,无法回溯
串联键traceId、orderId、sessionId一次旅程被拆成多个陌生用户
来源client 还是 server端上估算被当成服务端事实

六类事件的最小字段

trade-lab 只保留交易分析真正需要的六类事件。字段不求多,但必填项必须能支撑「谁、在哪个页面、对哪个商品、哪笔订单、发生了什么」。

事件必填属性是否要求 orderId
trade.product_impressionskuId、page、position否
trade.product_clickskuId、page、position否
trade.cart_addskuId、quantity、sourcePage否
trade.order_submitamountCents、itemCount是
trade.order_payamountCents、payId、channel是
trade.order_refundrefundId、amountCents、reason是

命名与版本:先定规则再谈采集

命名规则只有一条:小写命名空间、点号分隔、动作部分用下划线,例如 trade.product_impression。它排除了 Trade.ProductImpression、product_impression、trade.click 这些常见写法。

版本规则同样只有一条:破坏性变更必须升 schemaVersion。删除字段、改字段类型、把选填改必填、删除枚举值,都是破坏性变更;新增选填字段、给枚举加值、把字段标为废弃,是兼容变更。规则写进代码,评审时就不用凭记忆争论「这算不算 breaking」。

一个可运行的契约模块

下面的模块属于 L1:只有类型、查表和纯函数,没有网络、没有定时器、不读系统时间。now 由调用方传入,测试可以构造任意时间点。

// file: src/analytics/event-contract.ts
// tier: L1
// verify: node --test test/analytics/event-contract.test.ts

export const SCHEMA_VERSION = 2;
export const CLOCK_SKEW_MS = 5 * 60_000;
export const MAX_EVENT_AGE_MS = 7 * 24 * 60 * 60_000;

export type EventSource = "client" | "server";
export type TradeEventName =
  | "trade.product_impression"
  | "trade.product_click"
  | "trade.cart_add"
  | "trade.order_submit"
  | "trade.order_pay"
  | "trade.order_refund";
export type FieldType = "string" | "int" | "bool" | "enum";

export interface FieldSpec {
  type: FieldType;
  required: boolean;
  values?: readonly string[];
  deprecated?: boolean;
}
export type EventSchema = Record<string, FieldSpec>;

export interface TradeEvent {
  eventId: string;
  name: TradeEventName;
  schemaVersion: number;
  occurredAt: number;
  sessionId: string;
  traceId: string;
  orderId?: string;
  source: EventSource;
  props: Record<string, unknown>;
}
export interface ValidationResult {
  ok: boolean;
  errors: string[];
  warnings: string[];
}

const NAME_PATTERN = /^[a-z][a-z0-9]*\.[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/;
const ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;

export const EVENT_SCHEMAS: Record<TradeEventName, EventSchema> = {
  "trade.product_impression": {
    skuId: { type: "string", required: true },
    page: { type: "string", required: true },
    position: { type: "int", required: true },
    campaignId: { type: "string", required: false },
  },
  "trade.product_click": {
    skuId: { type: "string", required: true },
    page: { type: "string", required: true },
    position: { type: "int", required: true },
  },
  "trade.cart_add": {
    skuId: { type: "string", required: true },
    quantity: { type: "int", required: true },
    sourcePage: { type: "string", required: true },
  },
  "trade.order_submit": {
    amountCents: { type: "int", required: true },
    itemCount: { type: "int", required: true },
    couponId: { type: "string", required: false, deprecated: true },
  },
  "trade.order_pay": {
    amountCents: { type: "int", required: true },
    payId: { type: "string", required: true },
    channel: { type: "enum", required: true, values: ["wechat", "alipay", "card"] },
  },
  "trade.order_refund": {
    refundId: { type: "string", required: true },
    amountCents: { type: "int", required: true },
    reason: {
      type: "enum",
      required: true,
      values: ["quality", "logistics", "wrong_item", "other"],
    },
  },
};

const ORDER_EVENTS: readonly TradeEventName[] = [
  "trade.order_submit",
  "trade.order_pay",
  "trade.order_refund",
];

export function isValidEventName(name: string): boolean {
  return NAME_PATTERN.test(name);
}
export function isKnownEvent(name: string): name is TradeEventName {
  return Object.prototype.hasOwnProperty.call(EVENT_SCHEMAS, name);
}
export function isOrderEvent(name: string): boolean {
  return ORDER_EVENTS.includes(name as TradeEventName);
}

function checkField(
  key: string,
  spec: FieldSpec,
  value: unknown,
  errors: string[],
  warnings: string[],
): void {
  if (value === undefined) {
    if (spec.required) errors.push(`missing_required:${key}`);
    return;
  }
  if (spec.deprecated) warnings.push(`deprecated:${key}`);
  if (spec.type === "int") {
    if (!Number.isSafeInteger(value)) errors.push(`type:${key}:int`);
  } else if (spec.type === "bool") {
    if (typeof value !== "boolean") errors.push(`type:${key}:bool`);
  } else if (typeof value !== "string" || value.trim() === "") {
    errors.push(`type:${key}:string`);
  } else if (spec.type === "enum" && !(spec.values ?? []).includes(value)) {
    errors.push(`enum:${key}:${value}`);
  }
}

export function validateEvent(event: TradeEvent, now?: number): ValidationResult {
  const errors: string[] = [];
  const warnings: string[] = [];
  if (!ID_PATTERN.test(event.eventId)) errors.push("event_id:format");
  if (!isValidEventName(event.name)) {
    errors.push("name:format");
  } else if (!isKnownEvent(event.name)) {
    errors.push(`name:unknown:${event.name}`);
    return { ok: false, errors, warnings };
  }
  if (event.schemaVersion !== SCHEMA_VERSION) {
    errors.push(`schema_version:${event.schemaVersion}!=${SCHEMA_VERSION}`);
  }
  if (!Number.isSafeInteger(event.occurredAt) || event.occurredAt <= 0) {
    errors.push("occurred_at:invalid");
  } else if (now !== undefined) {
    if (event.occurredAt - now > CLOCK_SKEW_MS) errors.push("occurred_at:future");
    if (now - event.occurredAt > MAX_EVENT_AGE_MS) warnings.push("occurred_at:stale");
  }
  if (event.sessionId.trim() === "") errors.push("session_id:empty");
  if (event.traceId.trim() === "") errors.push("trace_id:empty");
  if (isOrderEvent(event.name)) {
    if (typeof event.orderId !== "string" || event.orderId.trim() === "") {
      errors.push("order_id:required");
    }
  } else if (event.orderId !== undefined && event.orderId.trim() === "") {
    errors.push("order_id:empty");
  }
  const schema = EVENT_SCHEMAS[event.name];
  const props = event.props;
  if (props === null || typeof props !== "object" || Array.isArray(props)) {
    errors.push("props:not_object");
    return { ok: errors.length === 0, errors, warnings };
  }
  for (const key of Object.keys(schema)) {
    checkField(key, schema[key], props[key], errors, warnings);
  }
  for (const key of Object.keys(props)) {
    if (!Object.prototype.hasOwnProperty.call(schema, key)) {
      warnings.push(`unknown_field:${key}`);
    }
  }
  return { ok: errors.length === 0, errors, warnings };
}

export interface TraceIssue {
  traceId: string;
  kind: "order_conflict" | "missing_submit" | "session_conflict";
  detail: string;
}
export interface TraceReport {
  traces: number;
  issues: TraceIssue[];
}

export function linkTrace(events: TradeEvent[]): TraceReport {
  const groups = new Map<string, TradeEvent[]>();
  for (const event of events) {
    const list = groups.get(event.traceId);
    if (list === undefined) groups.set(event.traceId, [event]);
    else list.push(event);
  }
  const issues: TraceIssue[] = [];
  for (const [traceId, list] of groups) {
    const sessions = new Set(list.map((event) => event.sessionId));
    if (sessions.size > 1) {
      issues.push({
        traceId,
        kind: "session_conflict",
        detail: `sessions=${[...sessions].join(",")}`,
      });
    }
    const orders = new Set(
      list
        .map((event) => event.orderId)
        .filter((id): id is string => typeof id === "string" && id.trim() !== ""),
    );
    if (orders.size > 1) {
      issues.push({
        traceId,
        kind: "order_conflict",
        detail: `orders=${[...orders].join(",")}`,
      });
    }
    const hasOrderEvent = list.some((event) => isOrderEvent(event.name));
    const hasSubmit = list.some((event) => event.name === "trade.order_submit");
    if (hasOrderEvent && !hasSubmit) {
      issues.push({ traceId, kind: "missing_submit", detail: "没有 order_submit 起点" });
    }
  }
  return { traces: groups.size, issues };
}

export interface SchemaDiff {
  breaking: string[];
  compatible: string[];
}

export function diffSchemas(previous: EventSchema, next: EventSchema): SchemaDiff {
  const breaking: string[] = [];
  const compatible: string[] = [];
  for (const key of Object.keys(previous)) {
    const before = previous[key];
    const after = next[key];
    if (after === undefined) {
      breaking.push(`removed:${key}`);
      continue;
    }
    if (before.type !== after.type) {
      breaking.push(`type:${key}`);
      continue;
    }
    if (!before.required && after.required) breaking.push(`required:${key}`);
    if (before.type === "enum" && after.type === "enum") {
      const afterValues = after.values ?? [];
      const removed = (before.values ?? []).filter((value) => !afterValues.includes(value));
      if (removed.length > 0) breaking.push(`enum_removed:${key}:${removed.join(",")}`);
      const added = afterValues.filter((value) => !(before.values ?? []).includes(value));
      if (added.length > 0) compatible.push(`enum_added:${key}`);
    }
    if (!before.deprecated && after.deprecated) compatible.push(`deprecated:${key}`);
  }
  for (const key of Object.keys(next)) {
    if (previous[key] !== undefined) continue;
    if (next[key].required) breaking.push(`added_required:${key}`);
    else compatible.push(`added_optional:${key}`);
  }
  return { breaking, compatible };
}

export function requiresVersionBump(diff: SchemaDiff): boolean {
  return diff.breaking.length > 0;
}

三个函数的分工很清楚:

  • validateEvent 把问题分成 errors 与 warnings。缺必填字段、类型不对、版本不匹配是错误;未知字段、已废弃字段只给警告,避免旧端一次升级就被全量拒绝。
  • linkTrace 按 traceId 分组,检查同一链路里是否出现多个 orderId、是否跨了会话、是否有支付却没有提交起点。它不修改事件,只报告问题。
  • diffSchemas 比较两份 schema,把变更分成破坏性与兼容两类。requiresVersionBump 直接回答「这次改动要不要升版本」。

测试与真实输出

本机运行结果如下:

$ node --test test/analytics/event-contract.test.ts
✔ 事件名必须是命名空间加下划线动作 (0.696708ms)
✔ 注册表覆盖六类交易事件 (0.09025ms)
✔ 规范曝光事件通过校验 (0.5655ms)
✔ 缺失必填字段进入错误列表 (0.094417ms)
✔ 枚举值不合法时给出字段级错误 (0.0965ms)
✔ 未声明字段只产生警告 (0.071459ms)
✔ 已废弃字段可用但产生迁移警告 (0.083334ms)
✔ schema 版本不匹配时拒绝 (0.109167ms)
✔ 时间戳超前于本地时钟时拒绝 (0.082167ms)
✔ 超过保留期的事件给过期警告 (0.112584ms)
✔ 订单事件缺少 orderId 时拒绝 (0.068416ms)
✔ 同一 trace 内订单号冲突被发现 (0.170542ms)
✔ trace 中只有支付没有提交时报告缺起点 (0.061583ms)
✔ 同一 trace 跨会话被报告 (0.056417ms)
✔ 完整下单支付链路没有串联问题 (0.054666ms)
✔ schema 对比把删除、改类型、新增必填标为 breaking (0.143667ms)
✔ 新增选填字段与标记废弃是兼容变更 (0.075166ms)
ℹ tests 17
ℹ suites 0
ℹ pass 17
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 212.073083

十七条用例覆盖:命名格式、注册表、规范事件、缺必填、枚举、未知字段、废弃字段、版本、未来时间、过期时间、订单号必填、订单冲突、缺提交起点、跨会话、完整链路、两类 schema 差异。

一次「同名事件两个口径」的复盘

某交易客户端有一个沿用很久的事件叫 pay_success。商详页的「立即购买」在拉起支付渠道后上报一次,支付回跳页再上报一次,两个团队各取自己关心的那一条,于是同一个事件有了两个分母。

复盘发现三个叠加问题:

  1. 没有来源字段。渠道回跳被当成服务端确认,端上把「不确定」写成了成功。
  2. 没有稳定 eventId。同一笔支付生成两个 id,服务端去重失效,GMV 被重复累加。
  3. 没有版本闸门。老版本端继续上报旧字段,新报表按新 schema 解析,缺字段的行被静默丢掉。

修复分三步:把 pay_success 拆成 trade.order_pay(端上发起)与服务端支付确认两条来源明确的事件;给每条事件生成稳定的 eventId,重试复用;在采集入口校验 schemaVersion,不匹配的事件进入隔离表而不是直接丢弃。

端上能保证与不能保证的

端上能保证三件事:事件在产生时带上完整信封;同一业务动作复用同一个 eventId;订单类事件在上报时带上已知的 orderId 与 traceId。但事件是否送达、是否等于服务端事实,端上无法保证。

小结

交易埋点的起点不是「多打几个点」,而是一份能被机器校验的契约:六类事件、必填字段、稳定命名、显式版本、可串联的 orderId 与 traceId,以及区分客户端与服务器来源的标记。

validateEvent 守住字段,linkTrace 守住链路,diffSchemas 守住演进。三者都很小,但它们让「这条数据能不能用」从口头争论变成一次函数调用。

阅读导航:上一节:11.3 工单会话的离线与恢复 · 下一节:12.2 上报队列、离线缓存与去重 。

常见错误与边界

  • 同一业务动作每次重试都生成新的 eventId,服务端去重完全失效。
  • 端上把渠道回跳写成支付成功,来源标记仍是 client,下游却当服务端事实使用。
  • 事件名用驼峰或缺少命名空间,两个团队的同名事件在报表里被合并。
  • 删除字段或改类型却不升 schemaVersion,新旧端数据混在一起无法回溯。
  • 订单类事件不带 orderId,支付与退款无法挂回同一笔订单。
  • traceId 在每个页面重新生成,一次下单旅程被拆成多条互不相干的链路。
  • 把未知字段当错误直接拒收,老端升级期间整批事件丢失。
  • 校验只做类型不做枚举,渠道名写错后报表里多出一个不存在的支付方式。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查