POST · 前端 · 笔记
前端埋点方案
2026/11/13 · by ZHANG XING YUN · 阅读 1 次 · 5709 字 · 预计阅读 15 分钟
本文整理一套可落地的前端埋点(数据采集)方案:埋点类型与选型、事件模型与命名规范、SDK 设计(采集、队列、上报、降级)、自动埋点实现、隐私合规、数据治理与验收,最后给出方案对比与常见问题。
- 适用场景:Web / SPA / 微前端 / Electron 渲染层的数据采集
- 技术栈:TypeScript,框架无关(Vue / React 均可接入)
- 目标:埋点不丢、不重、可解释、可演进
一、先明确要回答什么问题
埋点不是"把点击都记下来",而是为了回答具体问题。开始设计前先写下要回答的问题:
| 问题 | 需要的数据 |
|---|---|
| 新功能有没有人用? | 功能入口曝光、点击、完成率 |
| 用户在哪个步骤流失? | 漏斗各步骤事件与顺序 |
| 页面加载慢不慢? | 性能指标(LCP、INP、CLS、接口耗时) |
| 出错了没有? | JS 错误、接口失败、资源加载失败 |
| 哪个版本/渠道效果好? | 版本号、渠道、实验分组 |
没有对应问题的埋点不要加——每一个事件都是长期维护成本。
三个原则:
- 先有指标,再有事件:事件服务于指标,指标服务于决策。
- 少而准:20 个设计良好的事件,胜过 200 个随手加的。
- 可解释:任何人看到事件名与属性,能判断它什么时候触发、代表什么。
二、埋点类型与选择
| 类型 | 做法 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| 代码埋点(手动) | 在业务代码里显式调用 track() |
语义准确、属性可控 | 有开发成本、易漏埋 | 核心业务、转化漏斗 |
| 全埋点(无埋点) | SDK 自动采集所有点击/页面 | 零开发成本、能回溯 | 语义模糊、数据量大、难分析 | 探索性分析、补漏 |
| 可视化埋点 | 在页面上圈选元素生成规则 | 运营可自助 | 依赖 DOM 结构、易失效 | 运营活动页、A/B |
| 曝光埋点 | IntersectionObserver 监听可见 | 精确统计曝光 | 需处理阈值与去重 | 列表、Banner、广告 |
| 服务端埋点 | 后端记录业务事件 | 不受前端影响、可信 | 缺前端上下文 | 支付、订单等关键链路 |
实践组合:
- 核心链路(注册、下单、支付)用代码埋点,保证语义与属性完整。
- 页面与入口用全埋点或路由埋点兜底,避免漏埋。
- 关键转化加服务端埋点做交叉校验。
- 曝光用专门的曝光埋点,不要用点击代替。
三、事件模型与命名规范
3.1 统一事件结构
{
"event": "order_submit",
"event_id": "0f3c2b1a-8f6d-4c1e-9a2b-7d5e4f3c2b1a",
"ts": 1768000000000,
"app_id": "web-shop",
"app_version": "1.12.0",
"env": "prod",
"user_id": "u_10086",
"anonymous_id": "anon_9f2c",
"session_id": "s_7d1e",
"page": {
"path": "/order/confirm",
"title": "确认订单",
"referrer": "https://example.com/cart"
},
"properties": {
"order_id": "20260113001",
"amount": 19900,
"currency": "CNY",
"sku_count": 2
},
"context": {
"ua": "Mozilla/5.0 ...",
"screen": "1440x900",
"locale": "zh-CN",
"timezone": "Asia/Shanghai",
"platform": "web",
"sdk_version": "2.3.1"
}
}
字段分层:
| 层 | 字段 | 谁来填 |
|---|---|---|
| 业务层 | event、properties |
业务代码 |
| 身份层 | user_id、anonymous_id、session_id |
SDK 自动 |
| 页面层 | page.path、page.title、page.referrer |
SDK 自动 |
| 环境层 | app_id、app_version、env、context |
SDK 自动 |
| 质量层 | event_id、ts、sdk_version、sampled |
SDK 自动 |
3.2 命名规范
| 对象 | 规范 | 示例 |
|---|---|---|
| 事件名 | 对象_动作,小写下划线 |
page_view、button_click、order_submit |
| 属性名 | 小写下划线 | order_id、sku_count、from_page |
| 布尔属性 | 用 is_ / has_ 前缀 |
is_new_user |
| 时间属性 | 用 _at 后缀,毫秒时间戳 |
submit_at |
| 枚举值 | 全小写,固定取值,写进字典 | pay_type:alipay / wechat |
| 金额 | 用整数分,属性名带 _cents 或统一约定 |
amount_cents |
禁止:
- 使用无意义的名字(
click1、event_a)。 - 属性里放 JSON 字符串(应拆成结构化字段)。
- 同一含义多个名字(
order_id与orderId混用)。 - 用中文或空格作为事件名。
3.3 版本管理
事件一旦上线就进入"契约"状态,改名或删除属性会造成历史数据断裂。做法:
- 事件与属性维护在事件字典(Excel、JSON、数据库均可),带负责人与生效版本。
- 破坏性变更走新事件名(
order_submit_v2),旧事件保留至少一个版本周期。 - 上报字段
schema_version,便于服务端兼容。
四、指标体系与常见事件
4.1 四类基础指标
| 类别 | 指标 | 数据来源 |
|---|---|---|
| 流量 | PV、UV、新老用户、来源 | 页面事件 + 身份 |
| 行为 | 点击、曝光、停留、滚动 | 交互事件 |
| 转化 | 漏斗各步骤、完成率 | 业务事件 |
| 质量 | 错误率、接口耗时、Web Vitals | 错误与性能事件 |
4.2 常见事件清单
| 事件 | 触发时机 | 关键属性 |
|---|---|---|
page_view |
路由进入(含 SPA 切换) | path、title、referrer、from |
page_leave |
路由离开或页面卸载 | path、duration_ms、scroll_depth |
element_click |
点击可交互元素 | element_id、element_type、element_text |
element_exposure |
元素进入视口并停留 | element_id、duration_ms、ratio |
search_submit |
搜索提交 | keyword(脱敏)、result_count |
order_submit |
下单成功 | order_id、amount_cents、sku_count |
api_error |
接口失败 | url、status、code、duration_ms |
js_error |
脚本错误 | message、stack、file、line |
web_vitals |
页面性能指标 | lcp、inp、cls、ttfb |
五、SDK 架构
业务代码 / 自动采集
│
▼
┌─────────┐ 加工:补全上下文、过滤、脱敏、采样
│ Core │──────────────────────────────┐
└─────────┘ │
│ ▼
│ ┌────────────┐
├───────────────────────────►│ 队列 Queue │
│ └────────────┘
│ │ 批量 / 定时 / 阈值
│ ▼
│ ┌────────────┐
└───────────────────────────►│ Transport │──► HTTP / Beacon
└────────────┘
│ 失败
▼
┌────────────┐
│ 本地缓冲 │(下次启动重发)
└────────────┘
模块职责:
| 模块 | 职责 |
|---|---|
| Core | 初始化配置、事件组装、插件调度 |
| Context | 采集 UA、屏幕、locale、版本、页面信息 |
| Identity | 匿名 ID、登录 ID、会话 ID |
| Filter | 采样、白名单、黑名单、限流 |
| Sanitizer | PII 脱敏、字段截断 |
| Queue | 缓冲、批量、优先级、上限与丢弃策略 |
| Transport | fetch / sendBeacon、重试、超时 |
| Storage | 离线缓冲、ID 持久化、consent 状态 |
| Plugin | 自动埋点、性能、错误、路由 |
六、采集层实现
6.1 核心类型
// types.ts
export interface TrackEvent {
event: string;
properties?: Record<string, unknown>;
}
export interface EventRecord extends TrackEvent {
event_id: string;
ts: number;
app_id: string;
app_version: string;
env: "dev" | "test" | "prod";
user_id: string | null;
anonymous_id: string;
session_id: string;
schema_version: number;
page: { path: string; title: string; referrer: string };
context: Record<string, unknown>;
}
export interface InitOptions {
appId: string;
appVersion: string;
env?: "dev" | "test" | "prod";
endpoint: string;
flushInterval?: number; // 毫秒
batchSize?: number;
maxQueueSize?: number;
sampleRate?: number;
debug?: boolean;
}
6.2 身份与会话
// identity.ts
const ANON_KEY = "track_anon_id";
const SESSION_KEY = "track_session_id";
const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 分钟无操作视为新会话
function randomId(prefix: string): string {
const value =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
return `${prefix}_${value}`;
}
export class Identity {
private anonymousId: string;
private sessionId: string;
private userId: string | null = null;
private lastActiveAt = Date.now();
constructor(private readonly storage: Storage) {
this.anonymousId = storage.getItem(ANON_KEY) ?? randomId("anon");
storage.setItem(ANON_KEY, this.anonymousId);
this.sessionId = this.readOrCreateSession();
}
get ids() {
return { anonymousId: this.anonymousId, sessionId: this.sessionId, userId: this.userId };
}
login(userId: string): void {
this.userId = userId;
}
logout(): void {
this.userId = null;
this.resetAnonymous();
}
touch(): void {
const now = Date.now();
if (now - this.lastActiveAt > SESSION_TIMEOUT) {
this.sessionId = randomId("s");
this.storage.setItem(SESSION_KEY, this.sessionId);
}
this.lastActiveAt = now;
}
private readOrCreateSession(): string {
const existing = this.storage.getItem(SESSION_KEY);
if (existing) return existing;
const created = randomId("s");
this.storage.setItem(SESSION_KEY, created);
return created;
}
private resetAnonymous(): void {
this.anonymousId = randomId("anon");
this.storage.setItem(ANON_KEY, this.anonymousId);
}
}
要点:
anonymous_id持久化在localStorage,用于未登录用户去重。- 登录后同时上报
user_id与anonymous_id,服务端做账号归一。 - 退出登录时重置匿名 ID,避免多账号共享设备标识。
- 会话按"30 分钟无操作"切分,避免一次会话跨天。
6.3 上下文
// context.ts
export function buildContext(): Record<string, unknown> {
return {
ua: navigator.userAgent,
screen: `${window.screen.width}x${window.screen.height}`,
viewport: `${window.innerWidth}x${window.innerHeight}`,
locale: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
platform: "web",
referrer: document.referrer
};
}
七、队列与上报
7.1 队列策略
| 参数 | 建议值 | 说明 |
|---|---|---|
batchSize |
10~20 | 一次上报的事件数 |
flushInterval |
5000ms | 定时上报 |
maxQueueSize |
200 | 超过后丢最旧或按优先级丢弃 |
| 采样率 | 关键事件 100%,普通事件 10%~50% | 采样必须在同一维度保持一致 |
| 重试次数 | 3 次 | 指数退避 + 随机抖动 |
7.2 传输实现
// transport.ts
export interface SendResult {
ok: boolean;
retryable: boolean;
}
export class Transport {
constructor(private readonly endpoint: string) {}
async send(records: unknown[]): Promise<SendResult> {
const body = JSON.stringify({ events: records });
try {
const response = await fetch(this.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
keepalive: true,
credentials: "omit"
});
// 4xx 多为数据格式问题,重试无意义
return { ok: response.ok, retryable: response.status >= 500 || response.status === 429 };
} catch {
return { ok: false, retryable: true };
}
}
// 页面卸载时优先用 sendBeacon:不阻塞卸载、浏览器保证尽量发出
beacon(records: unknown[]): boolean {
if (typeof navigator === "undefined" || !navigator.sendBeacon) return false;
const blob = new Blob([JSON.stringify({ events: records })], { type: "application/json" });
// 单个 beacon 请求体有大小限制(约 64KB),超限要拆分
return navigator.sendBeacon(this.endpoint, blob);
}
}
选择依据:
| 通道 | 适用 | 注意 |
|---|---|---|
fetch |
常规批量上报 | 页面卸载时可能被取消,需 keepalive |
sendBeacon |
卸载前最后一批 | 只支持 POST,无响应读取,有体积上限 |
Image |
极简场景 | 只能 GET,参数长度受限 |
| WebSocket | 实时性要求高 | 连接维护成本高,断线要补发 |
7.3 退避与重试
// retry.ts
export function backoffDelay(attempt: number, base = 500, max = 15000): number {
const exponential = Math.min(base * 2 ** attempt, max);
// 加抖动,避免大量客户端同时重试
return Math.floor(exponential * (0.5 + Math.random() * 0.5));
}
7.4 页面卸载处理
// lifecycle.ts
export function bindFlushOnHide(flush: (final: boolean) => void): void {
const handler = () => {
if (document.visibilityState === "hidden") {
flush(true); // 进入后台或关闭页面时立即上报
}
};
document.addEventListener("visibilitychange", handler);
window.addEventListener("pagehide", handler);
// 不要用 unload:移动端不可靠,且会阻塞 bfcache
}
visibilitychange + pagehide 的组合比 unload 可靠,且不影响浏览器的前进后退缓存(bfcache)。
7.5 离线缓冲
// buffer.ts
const BUFFER_KEY = "track_buffer";
const BUFFER_LIMIT = 100;
export function saveToBuffer(records: unknown[]): void {
const existing = JSON.parse(localStorage.getItem(BUFFER_KEY) ?? "[]") as unknown[];
const merged = [...existing, ...records].slice(-BUFFER_LIMIT);
try {
localStorage.setItem(BUFFER_KEY, JSON.stringify(merged));
} catch {
// 存储满:清空后重试一次
localStorage.removeItem(BUFFER_KEY);
}
}
export function drainBuffer(): unknown[] {
const raw = localStorage.getItem(BUFFER_KEY);
localStorage.removeItem(BUFFER_KEY);
return raw ? (JSON.parse(raw) as unknown[]) : [];
}
本地缓冲要设上限,否则存储写满会导致 setItem 抛错、影响业务代码。
八、自动埋点
8.1 点击
// plugins/click.ts
export function initClickTracking(track: (event: string, props: Record<string, unknown>) => void): () => void {
const handler = (event: MouseEvent) => {
const target = (event.target as Element | null)?.closest?.("[data-track]");
if (!target) return;
const rect = target.getBoundingClientRect();
track("element_click", {
element_id: target.getAttribute("data-track") ?? "",
element_type: target.tagName.toLowerCase(),
element_text: (target.textContent ?? "").trim().slice(0, 50),
page_x: Math.round(event.clientX),
page_y: Math.round(event.clientY),
element_width: Math.round(rect.width),
element_height: Math.round(rect.height)
});
};
// 捕获阶段监听,避免业务 stopPropagation 导致漏埋
document.addEventListener("click", handler, true);
return () => document.removeEventListener("click", handler, true);
}
约定:需要埋点的元素加 data-track="submit_order",避免"全量点击"带来的噪音与性能开销。
8.2 曝光
// plugins/exposure.ts
export interface ExposureOptions {
threshold?: number; // 可见比例
minDuration?: number; // 至少可见多久(毫秒)
once?: boolean; // 同一元素是否只上报一次
}
export function initExposureTracking(
track: (event: string, props: Record<string, unknown>) => void,
options: ExposureOptions = {}
): () => void {
const { threshold = 0.5, minDuration = 1000, once = true } = options;
const timers = new WeakMap<Element, number>();
const reported = new WeakSet<Element>();
const observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
const el = entry.target;
if (entry.isIntersecting && entry.intersectionRatio >= threshold) {
const startAt = Date.now();
timers.set(el, startAt);
continue;
}
const startAt = timers.get(el);
if (!startAt) continue;
timers.delete(el);
const duration = Date.now() - startAt;
if (duration < minDuration) continue;
if (once && reported.has(el)) continue;
reported.add(el);
track("element_exposure", {
element_id: el.getAttribute("data-track") ?? "",
duration_ms: duration,
ratio: Number(entry.intersectionRatio.toFixed(2))
});
}
},
{ threshold: [0, threshold, 1] }
);
const observe = () => {
document.querySelectorAll("[data-track]").forEach(el => observer.observe(el));
};
observe();
// 动态内容:用 MutationObserver 或路由变更后重新 observe
const mutation = new MutationObserver(() => observe());
mutation.observe(document.body, { childList: true, subtree: true });
return () => {
observer.disconnect();
mutation.disconnect();
};
}
曝光的三个关键参数:可见比例、最短停留时间、是否只报一次。不设停留时间会把"快速划过"也算成曝光,数据虚高。
8.3 页面浏览与路由
// plugins/page.ts
export function initPageTracking(
track: (event: string, props: Record<string, unknown>) => void
): () => void {
let lastPath = "";
let enteredAt = Date.now();
function onRouteChange() {
const path = `${location.pathname}${location.search}`;
if (path === lastPath) return;
if (lastPath) {
track("page_leave", { path: lastPath, duration_ms: Date.now() - enteredAt });
}
lastPath = path;
enteredAt = Date.now();
track("page_view", {
path,
title: document.title,
referrer: document.referrer,
from: document.referrer ? "external" : "internal"
});
}
// 劫持 pushState / replaceState,覆盖 SPA 路由
const wrap = (type: "pushState" | "replaceState") => {
const original = history[type];
history[type] = function (this: History, ...args: Parameters<History["pushState"]>) {
const result = original.apply(this, args);
queueMicrotask(onRouteChange);
return result;
} as History["pushState"];
};
wrap("pushState");
wrap("replaceState");
window.addEventListener("popstate", onRouteChange);
window.addEventListener("hashchange", onRouteChange);
onRouteChange();
return () => {
window.removeEventListener("popstate", onRouteChange);
window.removeEventListener("hashchange", onRouteChange);
};
}
注意:直接修改 history.pushState 是全局行为,多个 SDK 叠加时要幂等,避免重复包装。
8.4 性能与错误
// plugins/vitals.ts
export function initVitals(report: (metric: string, value: number) => void): void {
const observe = (type: string, handler: (entry: PerformanceEntry) => void) => {
try {
const po = new PerformanceObserver(list => list.getEntries().forEach(handler));
po.observe({ type, buffered: true });
} catch {
// 浏览器不支持该类型时忽略
}
};
observe("largest-contentful-paint", entry => report("lcp", Math.round(entry.startTime)));
observe("layout-shift", entry => {
const shift = entry as PerformanceEntry & { value: number; hadRecentInput: boolean };
if (!shift.hadRecentInput) report("cls", Number(shift.value.toFixed(4)));
});
observe("first-input", entry => {
const first = entry as PerformanceEntry & { processingStart: number };
report("fid", Math.round(first.processingStart - first.startTime));
});
}
// plugins/error.ts
export function initErrorTracking(track: (event: string, props: Record<string, unknown>) => void): () => void {
const onError = (event: ErrorEvent) => {
track("js_error", {
message: String(event.message).slice(0, 300),
file: event.filename,
line: event.lineno,
column: event.colno,
stack: event.error?.stack?.slice(0, 1000)
});
};
const onRejection = (event: PromiseRejectionEvent) => {
track("unhandled_rejection", {
reason: String(event.reason?.message ?? event.reason).slice(0, 300)
});
};
window.addEventListener("error", onError);
window.addEventListener("unhandledrejection", onRejection);
return () => {
window.removeEventListener("error", onError);
window.removeEventListener("unhandledrejection", onRejection);
};
}
九、框架接入
9.1 Vue
// track.ts
import { createApp } from "vue";
import { track, init } from "./sdk";
init({ appId: "web-shop", appVersion: __APP_VERSION__, endpoint: "/collect" });
const app = createApp(App);
app.config.globalProperties.$track = track;
app.config.errorHandler = (error, instance, info) => {
track("vue_error", { message: String(error), info });
};
9.2 React
// TrackProvider.tsx
import { createContext, useContext, useEffect } from "react";
import { track } from "./sdk";
const TrackContext = createContext(track);
export const useTrack = () => useContext(TrackContext);
export function TrackProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
const handler = (event: ErrorEvent) => {
track("react_error", { message: event.message, stack: event.error?.stack });
};
window.addEventListener("error", handler);
return () => window.removeEventListener("error", handler);
}, []);
return <TrackContext.Provider value={track}>{children}</TrackContext.Provider>;
}
9.3 微前端
- 子应用不要各自初始化 SDK,由主应用统一初始化,子应用通过
window.__TRACK__或 props 使用。 - 子应用上报时带
micro_app属性,便于区分来源。 - 路由埋点由主应用负责,避免多个子应用重复上报 PV。
十、隐私与合规
10.1 同意机制
// consent.ts
type ConsentState = "unknown" | "granted" | "denied";
export class Consent {
private state: ConsentState = (localStorage.getItem("track_consent") as ConsentState) ?? "unknown";
get allowed(): boolean {
return this.state === "granted";
}
grant(): void {
this.state = "granted";
localStorage.setItem("track_consent", "granted");
}
deny(): void {
this.state = "denied";
localStorage.setItem("track_consent", "denied");
// 清理已采集的标识
localStorage.removeItem("track_anon_id");
localStorage.removeItem("track_session_id");
}
}
原则:
- 未同意前不采集任何数据,也不写入持久化标识。
- 不同意时提供"基础功能可用"的路径,不要阻塞产品使用。
- 同意状态变化要立即生效(开启后补采当前会话、关闭后停止并清理)。
10.2 脱敏
// sanitizer.ts
const PATTERNS: Array<[RegExp, string]> = [
[/\b1[3-9]\d{9}\b/g, "[phone]"],
[/\b[\w.+-]+@[\w-]+\.[\w.]+\b/g, "[email]"],
[/\b\d{17}[\dXx]\b/g, "[id_card]"],
[/\b\d{16,19}\b/g, "[card_no]"]
];
const SENSITIVE_KEYS = new Set([
"password",
"token",
"authorization",
"cookie",
"secret",
"phone",
"email",
"id_card"
]);
export function sanitize(input: Record<string, unknown>): Record<string, unknown> {
const output: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
if (SENSITIVE_KEYS.has(key.toLowerCase())) {
output[key] = "[redacted]";
continue;
}
if (typeof value === "string") {
let text = value;
for (const [pattern, replacement] of PATTERNS) {
text = text.replace(pattern, replacement);
}
output[key] = text.slice(0, 500);
continue;
}
if (value && typeof value === "object") {
output[key] = sanitize(value as Record<string, unknown>);
continue;
}
output[key] = value;
}
return output;
}
必须做的三件事:
- 不采集输入框原文(除搜索词等明确需要的,也要脱敏)。
- 不采集 URL 中的敏感参数(
token、code、session)。 - 服务端二次脱敏,不依赖前端。
10.3 其它合规点
| 项 | 做法 |
|---|---|
| 数据最小化 | 只采集指标需要的最小字段 |
| 保留期限 | 明确定义,到期删除或聚合 |
| 用户权利 | 提供撤回同意、删除数据的入口 |
| 跨境传输 | 按法规评估存储地域 |
| 日志 | 埋点上报内容不得进入普通业务日志 |
十一、数据治理
11.1 事件字典
每个事件必须登记:
| 字段 | 示例 |
|---|---|
| 事件名 | order_submit |
| 中文名 | 提交订单 |
| 触发时机 | 订单创建成功且接口返回 200 |
| 属性 | order_id(string)、amount_cents(number) |
| 取值范围 | amount_cents > 0 |
| 负责人 | 订单团队 |
| 生效版本 | 1.12.0 |
11.2 质量校验
| 问题 | 检测方式 |
|---|---|
| 漏埋 | 对照事件字典与实际上报量(为 0 报警) |
| 重复 | 按 event_id 去重;检查同一会话内重复事件 |
| 错报 | 属性值分布异常(如 amount_cents 出现 0 或负数) |
| 属性缺失 | 必填属性空值率超过阈值 |
| 版本断档 | 按 app_version 统计上报量,新版本骤降要排查 |
11.3 环境与灰度
- 开发/测试环境上报到独立通道,不污染生产数据。
- 新埋点先小流量(10%)验证数据正确性,再全量。
- 埋点改动与业务版本绑定,发布记录里标注"埋点变更"。
十二、服务端配套
浏览器 ──► 采集网关(同域 /collect)
├─ 校验(schema、鉴权、限流、防刷)
├─ 清洗(脱敏、补 UA/Geo、会话还原)
├─ 落盘(Kafka / 对象存储)
└─ 入库(ClickHouse / BigQuery / Doris)
├─ 实时看板
└─ 离线数仓与报表
关键点:
- 网关与业务同域,经反向代理转发,减少 CORS 与广告拦截影响。
- 上报接口返回
204,减少响应体开销。 - 服务端按
event_id幂等去重,网络重试不会造成重复计数。 - 采样在客户端与服务端都要记录标记,保证口径一致。
十三、测试与验收
13.1 单元测试
// track.test.ts
import { describe, expect, it, vi } from "vitest";
describe("track", () => {
it("补齐上下文与身份", async () => {
const sent: unknown[] = [];
const sdk = createSdk({
appId: "test",
appVersion: "1.0.0",
endpoint: "/collect",
transport: { send: async records => { sent.push(...records); return { ok: true, retryable: false }; } }
});
sdk.track("button_click", { element_id: "save" });
await sdk.flush();
expect(sent).toHaveLength(1);
expect(sent[0]).toMatchObject({ event: "button_click", app_id: "test" });
});
});
Transport 通过依赖注入替换为假实现,测试不依赖网络。
13.2 调试面板
开发环境加一个开关(如 URL 带 ?track_debug=1)打开面板,实时显示:
- 事件名、属性、时间
- 队列长度、上报状态、失败原因
- 当前
anonymous_id、session_id
13.3 验收清单
- 每个事件在事件字典中登记
- 关键事件在测试环境验证属性完整、值域正确
- 页面卸载时最后一批事件不丢(
visibilitychange+ beacon) - 弱网/离线后恢复能补发
- 重复上报能在服务端按
event_id去重 - 采样率与口径与数据团队对齐
- PII 已脱敏(抽查上报内容)
- 未同意时不产生任何上报
- 微前端/多路由场景 PV 不重复、不遗漏
十四、方案对比与选型
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 自研 SDK | 完全可控、数据不出域、可裁剪 | 开发与维护成本高 | 数据敏感、需求特殊 |
| 神策 / GrowingIO | 分析能力强、可视化埋点 | 成本高、数据出境/合规评估 | 运营驱动、需要分析平台 |
| GA4 | 免费、生态成熟 | 数据出境、国内访问不稳定、模型复杂 | 海外产品 |
| 阿里云 ARMS / 腾讯 RUM | 与云资源集成、性能监控完善 | 绑定云厂商 | 已在用对应云 |
| Sentry | 错误与性能强 | 业务分析弱 | 稳定性优先 |
决策建议:
- 先评估合规(数据能否出境、是否涉及个人信息)。
- 再看分析需求(是否需要漏斗、留存、可视化埋点)。
- 最后看团队成本(自研至少需要 1 人长期维护 SDK 与治理)。
常见组合:自研采集 + 第三方分析/错误监控(数据先入自己的网关,再决定是否同步给第三方)。
十五、常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 页面关闭时事件丢失 | 用 unload 或普通 fetch |
改 visibilitychange + sendBeacon |
| SPA 切换页面 PV 不增加 | 只监听了 load |
劫持 pushState + 监听 popstate |
| 同一事件重复上报 | 重试无幂等、组件重复挂载 | 服务端按 event_id 去重;检查 effect 清理 |
| 曝光数据虚高 | 无停留时间阈值、未去重 | 加 minDuration 与 once |
| 埋点代码拖慢页面 | 同步计算、频繁上报 | 批量 + 定时 + 空闲时 flush |
| 数据量暴涨 | 全量点击、无采样 | 用 data-track 白名单 + 采样 |
| 广告拦截导致丢数据 | 上报域名被拦截 | 同域代理上报 |
| 移动端后台切换丢数据 | 页面被冻结 | 在 visibilitychange 时 flush |
| 属性值类型不一致 | 前端未做约束 | 事件字典定义类型,服务端校验 |
| 历史数据对不上 | 事件改名或口径变化 | 版本化事件、字典记录变更 |
十六、FAQ
Q1:全埋点能替代代码埋点吗?
不能。全埋点适合探索与补漏,但缺少业务语义(不知道"这个按钮代表下单")。核心链路必须代码埋点。
Q2:埋点会影响性能吗?
采集本身开销很小,主要成本在上报。用批量、采样、sendBeacon、空闲执行可以把影响控制在感知不到的范围。
Q3:如何避免漏埋?
双保险:代码埋点保证语义,路由与全局点击兜底;再用"事件字典 vs 实际上报量"的监控发现为 0 的事件。
Q4:采样后数据还能用吗?
能,但必须在同一维度(如按 session_id)采样,且 PV/UV 需要放大估算;不同事件采样率不同会导致漏斗不可比。
Q5:匿名 ID 和用户 ID 怎么配合?
未登录用 anonymous_id;登录后同时带 user_id;服务端把匿名 ID 归并到用户,退出登录后重置匿名 ID。
Q6:离线数据怎么补发?
本地缓冲 + 下次启动重放;缓冲有上限,溢出时优先保留关键事件。
Q7:如何做 A/B 实验的埋点?
所有事件都带 experiment_id 与 variant(由实验 SDK 注入),不要只在上报时补,否则会漏。
Q8:埋点数据能直接给运营看吗?
不建议。原始事件量大且含技术字段,应先经数仓建模(宽表、漏斗、留存)再出报表。
Q9:需要采集 IP 吗?
客户端拿不到真实 IP,服务端可从请求头获取;涉及个人信息时要脱敏或匿名化。
Q10:如何验证埋点没影响业务?
埋点 SDK 的异常必须被捕获(不能让埋点报错中断业务),并在 CI 中加入 SDK 的单测与冒烟。
附录:事件字典模板与检查清单
事件字典模板(JSON)
{
"event": "order_submit",
"name_cn": "提交订单",
"description": "订单创建成功且接口返回 200",
"owner": "order-team",
"since_version": "1.12.0",
"properties": {
"order_id": { "type": "string", "required": true, "example": "20260113001" },
"amount_cents": { "type": "number", "required": true, "min": 1, "example": 19900 },
"currency": { "type": "string", "required": true, "enum": ["CNY", "USD"] },
"sku_count": { "type": "number", "required": false, "example": 2 }
}
}
上线检查清单
- 有明确的分析目标,事件能对应到指标
- 事件名与属性符合命名规范,已登记字典
- 身份、页面、环境字段由 SDK 自动补齐
- 关键事件不采样,普通事件采样率与数据团队一致
- 队列有上限、有丢弃策略、有失败重试
- 卸载与后台切换时 flush,使用 beacon
- 离线缓冲并能在下次启动补发
- PII 与敏感字段已脱敏,未同意前不上报
- 服务端幂等去重,接口返回 204
- 有调试面板与线上监控(上报成功率、丢弃量)
- 灰度验证后再全量