前端埋点方案
一、这篇文章到底讨论什么
“前端埋点”这个词很容易被理解成“用户点了按钮,上报一个事件”。这个理解没有错,但太窄了。
在真实业务里,前端埋点通常会进一步演化成一套前端监控 SDK,它不仅采集用户行为,还要采集页面性能、运行时异常、接口异常、资源异常,并把这些数据稳定地上报到服务端,最终服务于产品分析、性能优化和线上问题排查。
所以本文讨论的不是单个 tracker.send() 怎么写,而是一个更完整的问题:
如果要从 0 到 1 设计一套前端监控 SDK,它应该采什么、怎么采、怎么上报、怎么治理,以及哪些地方最容易踩坑?
一套合格的前端监控体系至少要满足几个目标:
- 准确性:关键业务数据不能错报、漏报、重复报。
- 低侵入:不能让业务代码到处充满上报逻辑。
- 高可靠:页面关闭、网络波动、接口失败时尽量不丢数据。
- 可治理:事件名、字段、触发时机必须有规范,否则数据越采越脏。
- 可扩展:行为、性能、异常、接口监控最好能以插件形式扩展。
- 合规安全:不能随意采集手机号、身份证、Token 等敏感信息。
二、前端监控数据到底分哪几类
前端监控 SDK 采集的数据大体可以分为四类。
| 数据类型 | 典型数据 | 主要使用方 | 核心价值 |
|---|---|---|---|
| 用户行为数据 | PV、UV、点击、曝光、停留时长、页面路径 | 产品、运营、增长 | 分析用户怎么使用产品 |
| 性能数据 | FCP、LCP、INP、CLS、TTFB、资源耗时、长任务 | 前端、性能优化团队 | 判断页面快不快、卡不卡 |
| 异常数据 | JS 错误、Promise 错误、资源加载失败、接口异常 | 前端、稳定性团队 | 发现线上问题并定位原因 |
| 环境数据 | 浏览器、系统、网络、页面 URL、版本号、用户标识 | 所有人 | 给分析和排查提供上下文 |
这四类数据不是割裂的。一次线上问题排查通常需要把它们串起来看:
用户访问了哪个页面
↓
页面性能是否异常
↓
用户做了什么操作
↓
是否发生 JS 错误或接口异常
↓
错误出现在哪个版本、哪个浏览器、哪个用户环境
如果只上报错误堆栈,却没有页面、用户、版本、操作路径等上下文,监控数据的价值会大打折扣。
三、整体架构:从采集到消费的完整链路
一个前端监控 SDK 不应该只是“采集器”,它应该覆盖从事件产生到数据消费的完整链路。
浏览器 API / 业务代码
↓
采集层:click、route、error、performance、request
↓
标准化层:补充 appId、userId、sessionId、pageUrl、timestamp、version
↓
处理层:采样、去重、过滤、合并、脱敏
↓
队列层:内存队列、IndexedDB 缓存、失败重试
↓
上报层:sendBeacon、fetch keepalive、普通 fetch、图片打点
↓
服务端:鉴权、校验、清洗、入库、聚合、告警、可视化
这条链路里,前端最容易被忽略的是标准化层和处理层。
很多团队刚开始做埋点时,只关心事件能不能发出去。等数据量变大后才发现:
- 同一个按钮有三个事件名。
- 同一个字段有时叫
userId,有时叫uid。 - 某些页面的 PV 重复上报。
- 高频曝光事件把服务端打爆。
- 错误日志没有版本号,根本不知道是哪次发布引入的。
所以,埋点系统的难点从来不只是“采集”,而是长期可维护的数据治理。
四、统一事件模型
在实现具体采集能力之前,应该先定义统一事件模型。不要让每个模块随意拼字段,否则后续分析会非常痛苦。
interface TrackEvent {
eventName: string;
eventType: "behavior" | "performance" | "error" | "request";
timestamp: number;
appId: string;
pageUrl: string;
pageTitle: string;
referrer: string;
sessionId: string;
userId?: string;
deviceId: string;
releaseVersion: string;
sdkVersion: string;
properties?: Record<string, unknown>;
}
几个字段尤其关键:
appId:区分不同应用。sessionId:串联一次访问过程中的多个事件。deviceId:匿名用户也需要有稳定标识。releaseVersion:排查线上问题时必须知道错误来自哪个发布版本。properties:承载业务自定义字段,但必须有字段规范。
SDK 对外暴露的 API 可以很简单:
tracker.init({
appId: "blog-web",
endpoint: "https://track.example.com/report",
releaseVersion: "1.0.0",
sampleRate: 1,
});
tracker.track("pay_button_click", {
skuId: "SKU001",
price: 99,
});
业务方只关心事件名和业务参数,公共字段由 SDK 自动补齐。
五、三种埋点方案如何取舍
前端常见的埋点方式有三种:代码埋点、可视化埋点和无痕埋点。
| 方案 | 原理 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| 代码埋点 | 在业务代码中主动调用 SDK | 精准、语义清晰、可控性强 | 侵入业务、维护成本高 | 支付、注册、下单等核心链路 |
| 可视化埋点 | 在平台圈选元素并下发配置 | 无需发版、运营友好 | 依赖 DOM 稳定性,复杂逻辑难表达 | 活动页、运营位点击 |
| 无痕埋点 | 全局代理事件,自动采集行为 | 覆盖面广、接入成本低 | 数据噪声大、业务语义弱 | 行为回溯、数据兜底 |
我的建议是:核心链路用代码埋点,普通交互用声明式埋点,无痕埋点只做补充,不要把它当作核心指标来源。
1. 代码埋点:适合核心业务事件
async function submitOrder(orderId: string) {
tracker.track("order_submit_click", { orderId });
const result = await orderService.submit(orderId);
tracker.track("order_submit_result", {
orderId,
success: result.success,
});
return result;
}
代码埋点的优势是语义最准确。比如“支付成功”这种事件,只有业务代码最清楚什么时候才算成功,不能简单依赖按钮点击。
2. 声明式埋点:适合普通点击事件
<button data-track-event="pay_button_click" data-track-sku-id="SKU001">
支付
</button>
document.addEventListener("click", (event) => {
const target = (event.target as HTMLElement).closest("[data-track-event]");
if (!target) return;
const trackEvent = target.getAttribute("data-track-event");
if (!trackEvent) return;
const properties = { ...target.dataset };
delete properties.trackEvent;
tracker.track(trackEvent, properties);
});
这种方式比到处写 onClick={() => tracker.track(...)} 更干净,也更适合组件库统一封装。
3. 无痕埋点:适合兜底,不适合决策
无痕埋点通常通过事件代理采集所有点击,再根据 DOM 路径生成元素标识。
function getElementPath(element: HTMLElement): string {
if (element.id) {
return `#${element.id}`;
}
const paths: string[] = [];
let currentElement: HTMLElement | null = element;
while (currentElement && currentElement !== document.body) {
const tagName = currentElement.tagName.toLowerCase();
const siblings = Array.from(currentElement.parentElement?.children ?? []);
const index = siblings.indexOf(currentElement) + 1;
paths.unshift(`${tagName}:nth-child(${index})`);
currentElement = currentElement.parentElement;
}
return paths.join(" > ");
}
document.addEventListener("click", (event) => {
const target = event.target as HTMLElement;
tracker.track("auto_click", {
elementPath: getElementPath(target),
text: target.textContent?.trim().slice(0, 50),
});
});
但无痕埋点有天然缺陷:DOM 结构一变,元素路径就可能失效;列表、弹窗、虚拟滚动、Shadow DOM 都会增加识别难度。因此它更适合做行为回溯和数据补位,不适合作为核心转化指标的唯一依据。
六、页面 PV 与停留时长采集
SPA 应用里,页面不会整页刷新,传统的 load 事件无法准确表示页面切换,需要主动监听路由变化。
这里有一个常见错误:在 history.pushState 之前上报新页面 PV。此时 location.pathname 还是旧地址,很容易造成页面路径错乱。
更合理的思路是:
路由变化前:上报旧页面停留时长
路由变化后:更新当前页面地址
路由变化后:上报新页面 PV
let currentPageUrl = location.href;
let enterTime = Date.now();
function reportPageLeave() {
const now = Date.now();
tracker.track("page_leave", {
pageUrl: currentPageUrl,
duration: now - enterTime,
});
}
function reportPageView() {
currentPageUrl = location.href;
enterTime = Date.now();
tracker.track("page_view", {
pageUrl: currentPageUrl,
referrer: document.referrer,
});
}
function wrapHistoryMethod(methodName: "pushState" | "replaceState") {
const originalMethod = history[methodName];
history[methodName] = function (...args) {
reportPageLeave();
const result = originalMethod.apply(this, args as never);
reportPageView();
return result;
};
}
wrapHistoryMethod("pushState");
wrapHistoryMethod("replaceState");
window.addEventListener("popstate", () => {
reportPageLeave();
reportPageView();
});
window.addEventListener("hashchange", () => {
reportPageLeave();
reportPageView();
});
真实项目中还要注意:同一路由重复跳转要去重,页面首次进入要上报一次 PV,页面关闭时要补一次停留时长。
七、性能指标采集
性能监控不要只看 load 时间。现代前端更关注 Core Web Vitals:
| 指标 | 含义 | 关注点 |
|---|---|---|
| FCP | 首次内容绘制 | 用户什么时候看到内容 |
| LCP | 最大内容绘制 | 首屏主体内容什么时候加载完成 |
| INP | 交互到下一次绘制 | 页面交互是否卡顿 |
| CLS | 累积布局偏移 | 页面是否突然跳动 |
| TTFB | 首字节时间 | 服务端响应和网络链路是否慢 |
| Long Task | 超过 50ms 的长任务 | 主线程是否被阻塞 |
生产环境建议直接使用 web-vitals 这类成熟库,因为 INP、CLS 等指标的细节比较复杂,自己实现很容易不准确。
如果只讲核心思路,可以基于 PerformanceObserver 做基础采集:
function observePerformance() {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
tracker.track("performance_entry", {
name: entry.name,
entryType: entry.entryType,
startTime: entry.startTime,
duration: entry.duration,
});
}
});
observer.observe({ type: "paint", buffered: true });
observer.observe({ type: "largest-contentful-paint", buffered: true });
observer.observe({ type: "longtask", buffered: true });
observer.observe({ type: "resource", buffered: true });
}
性能数据最好和页面、设备、网络环境、版本号关联起来。否则只知道“LCP 很慢”,但不知道是哪个页面、哪个版本、哪类设备慢,优化就很难落地。
八、异常监控:不仅要捕获,还要能定位
异常监控的目标不是“把错误发到服务端”,而是让开发者能快速回答三个问题:
- 哪里错了?
- 影响了多少用户?
- 是哪次发布引入的?
1. JS 运行时错误
window.addEventListener(
"error",
(event) => {
const target = event.target as HTMLElement;
if (target && target.tagName) {
return;
}
tracker.track("js_error", {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error?.stack ?? "",
});
},
true
);
2. Promise 异常
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
tracker.track("promise_error", {
message: reason instanceof Error ? reason.message : String(reason),
stack: reason instanceof Error ? reason.stack ?? "" : "",
});
});
3. 资源加载失败
资源错误不会冒泡,所以必须在捕获阶段监听。
window.addEventListener(
"error",
(event) => {
const target = event.target as HTMLElement;
if (!target || !target.tagName) return;
const tagName = target.tagName.toLowerCase();
if (!["img", "script", "link", "video", "audio"].includes(tagName)) {
return;
}
tracker.track("resource_error", {
tagName,
url:
(target as HTMLImageElement).src ||
(target as HTMLLinkElement).href ||
"",
});
},
true
);
4. 接口异常
接口监控通常通过拦截 fetch 和 XMLHttpRequest 实现。这里以 fetch 为例:
const originalFetch = window.fetch;
window.fetch = async function monitoredFetch(input, init) {
const startTime = Date.now();
const requestUrl = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
try {
const response = await originalFetch.call(this, input, init);
const duration = Date.now() - startTime;
if (!response.ok) {
tracker.track("api_error", {
method,
url: requestUrl,
status: response.status,
duration,
});
}
return response;
} catch (error) {
tracker.track("api_error", {
method,
url: requestUrl,
status: 0,
duration: Date.now() - startTime,
message: error instanceof Error ? error.message : String(error),
});
throw error;
}
};
接口拦截要格外谨慎:不能改变业务请求的行为,不能吞掉异常,也不能读取过大的响应体,否则监控 SDK 本身就会变成风险源。
5. 异常监控的工程关键点
异常采集只是第一步,真正难的是定位。
- 跨域脚本错误:如果没有配置
crossorigin和正确的 CORS 头,浏览器可能只给出Script error.。 - Sourcemap 还原:线上代码压缩后,必须通过 sourcemap 把堆栈还原到源码位置。
- 错误去重:同一个错误短时间内可能重复上报,需要按
message + stack + filename + lineno + colno聚合。 - 版本关联:错误必须携带
releaseVersion,否则无法判断是哪次发布引入。 - 用户行为面包屑:错误发生前用户点击过什么、访问过哪些页面,往往比堆栈更有价值。
九、上报策略:如何尽量不丢数据
上报不是简单调用 fetch。不同事件的重要性和频率不同,策略也不同。
| 策略 | 说明 | 适合场景 |
|---|---|---|
| 即时上报 | 事件触发后立刻发送 | 支付、提交、异常等关键事件 |
| 批量上报 | 进入队列,满一定数量或间隔后发送 | 点击、曝光、性能条目等高频事件 |
| 页面卸载上报 | 页面隐藏或关闭时使用可靠通道发送 | 停留时长、最后一批队列数据 |
| 失败重试 | 上报失败后缓存,下次启动重发 | 网络不稳定场景 |
| 采样上报 | 按比例采集部分数据 | 高频事件、性能数据、非关键日志 |
1. 上报通道对比
| 通道 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
sendBeacon | 页面卸载时可靠性较高,不阻塞页面跳转 | 不能自定义请求头,数据大小有限 | 页面关闭、停留时长上报 |
fetch + keepalive | API 熟悉,卸载阶段也能发送 | 数据大小也有限,受浏览器实现约束 | 少量 JSON 数据上报 |
普通 fetch | 灵活,可设置请求头 | 页面关闭时可能被取消 | 常规批量上报 |
| 图片打点 | 跨域简单,实现成本低 | URL 长度受限,语义弱 | 轻量点击、兼容性兜底 |
sendBeacon 最好使用 Blob 明确数据类型:
function reportByBeacon(endpoint: string, data: TrackEvent[]) {
const payload = JSON.stringify(data);
const blob = new Blob([payload], { type: "application/json" });
return navigator.sendBeacon(endpoint, blob);
}
注意,sendBeacon 返回 true 只表示浏览器成功把数据加入发送队列,不代表服务端一定接收成功。
2. 批量队列
const eventQueue: TrackEvent[] = [];
const maxBatchSize = 10;
const flushInterval = 5000;
function enqueue(event: TrackEvent) {
eventQueue.push(event);
if (eventQueue.length >= maxBatchSize) {
flush();
}
}
function flush() {
if (eventQueue.length === 0) return;
const events = eventQueue.splice(0, eventQueue.length);
fetch("https://track.example.com/report", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(events),
}).catch(() => {
// 真实项目中应该写入 IndexedDB,等待下次重试。
});
}
setInterval(flush, flushInterval);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
reportByBeacon("https://track.example.com/report", eventQueue.splice(0));
}
});
这段代码只是核心思路。真实 SDK 还需要处理并发上报、失败回滚、最大缓存数量、页面关闭时队列清空等问题。
十、埋点治理:比写 SDK 更重要
很多埋点系统不是败在技术实现,而是败在治理。
如果没有治理,最终一定会出现这些问题:
- 事件名重复、歧义、拼写不统一。
- 字段含义不清晰,分析师不知道怎么用。
- 旧埋点没人下线,数据长期污染。
- 新需求临时加字段,历史数据无法兼容。
- 敏感信息被误上传。
建议至少建立以下规范:
| 治理项 | 要求 |
|---|---|
| 事件命名 | 使用统一格式,如 page_module_action |
| 字段规范 | 明确字段名、类型、是否必填、枚举值 |
| 触发时机 | 写清楚什么时候上报,避免重复理解 |
| 版本管理 | 字段变更需要记录版本和兼容策略 |
| 数据校验 | SDK 和服务端都要做基础校验 |
| 下线机制 | 废弃事件要有下线流程 |
| 隐私合规 | 手机号、身份证、Token 等敏感字段禁止直接上报 |
一条埋点在上线前,最好能回答清楚:
- 这个事件解决什么业务问题?
- 谁会消费这个数据?
- 事件什么时候触发?
- 是否可能重复触发?
- 字段是否有敏感信息?
- 后续如何验证数据是否正确?
十一、前端埋点常见坑
1. SPA 页面 PV 重复或漏报
路由变化、重定向、replaceState、hashchange 都可能影响 PV。不要只监听 pushState。
2. 页面关闭导致请求丢失
普通异步请求在页面关闭时可能被取消。离开页面时优先使用 visibilitychange + sendBeacon。
3. 曝光事件数据爆炸
曝光、滚动、鼠标移动这类事件必须节流、去重、采样,否则很容易把服务端打爆。
4. 错误日志没有 sourcemap
没有 sourcemap 的错误堆栈只是一堆压缩后的行列号,很难定位源码。
5. 无痕埋点 DOM 路径不稳定
DOM 一改,元素路径就变,数据连续性会被破坏。核心指标不要依赖无痕埋点。
6. 监控 SDK 污染业务逻辑
拦截 fetch、XMLHttpRequest、路由、全局错误时必须保持透明,不能改变业务原有行为。
7. 忽略隐私合规
不要上传密码、Token、手机号、身份证、详细地址等敏感信息。必要时做脱敏、白名单和字段过滤。
十二、总结
前端埋点不是简单地“点一下,上报一下”。一个成熟的前端监控 SDK 应该覆盖行为、性能、异常、接口和环境上下文,并且具备标准化、采样、去重、缓存、重试、合规和治理能力。
如果只记住一个结论,那就是:
核心业务数据靠代码埋点保证准确性,普通交互靠声明式埋点降低侵入,无痕埋点只做兜底;采集只是开始,治理才决定这套系统能不能长期用。
真正有价值的前端监控,不是收集最多的数据,而是用最可控的方式收集最有决策价值的数据。