POST · 前端 · 笔记

前端埋点方案

2026/11/13 · by ZHANG XING YUN · 阅读 1 次 · 5709 字 · 预计阅读 15 分钟

本文整理一套可落地的前端埋点(数据采集)方案:埋点类型与选型、事件模型与命名规范、SDK 设计(采集、队列、上报、降级)、自动埋点实现、隐私合规、数据治理与验收,最后给出方案对比与常见问题。

  • 适用场景:Web / SPA / 微前端 / Electron 渲染层的数据采集
  • 技术栈:TypeScript,框架无关(Vue / React 均可接入)
  • 目标:埋点不丢、不重、可解释、可演进

一、先明确要回答什么问题

埋点不是"把点击都记下来",而是为了回答具体问题。开始设计前先写下要回答的问题:

问题 需要的数据
新功能有没有人用? 功能入口曝光、点击、完成率
用户在哪个步骤流失? 漏斗各步骤事件与顺序
页面加载慢不慢? 性能指标(LCP、INP、CLS、接口耗时)
出错了没有? JS 错误、接口失败、资源加载失败
哪个版本/渠道效果好? 版本号、渠道、实验分组

没有对应问题的埋点不要加——每一个事件都是长期维护成本。

三个原则:

  1. 先有指标,再有事件:事件服务于指标,指标服务于决策。
  2. 少而准:20 个设计良好的事件,胜过 200 个随手加的。
  3. 可解释:任何人看到事件名与属性,能判断它什么时候触发、代表什么。

二、埋点类型与选择

类型 做法 优点 缺点 适用
代码埋点(手动) 在业务代码里显式调用 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;
}

必须做的三件事:

  1. 不采集输入框原文(除搜索词等明确需要的,也要脱敏)。
  2. 不采集 URL 中的敏感参数(token、code、session)。
  3. 服务端二次脱敏,不依赖前端。

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. 先评估合规(数据能否出境、是否涉及个人信息)。
  2. 再看分析需求(是否需要漏斗、留存、可视化埋点)。
  3. 最后看团队成本(自研至少需要 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
  • 有调试面板与线上监控(上报成功率、丢弃量)
  • 灰度验证后再全量
← 返回帖子 更多前端内容 →