前端埋点方案

22 分钟

一、这篇文章到底讨论什么

“前端埋点”这个词很容易被理解成“用户点了按钮,上报一个事件”。这个理解没有错,但太窄了。

在真实业务里,前端埋点通常会进一步演化成一套前端监控 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. 哪里错了?
  2. 影响了多少用户?
  3. 是哪次发布引入的?

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. 接口异常

接口监控通常通过拦截 fetchXMLHttpRequest 实现。这里以 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 + keepaliveAPI 熟悉,卸载阶段也能发送数据大小也有限,受浏览器实现约束少量 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. 这个事件解决什么业务问题?
  2. 谁会消费这个数据?
  3. 事件什么时候触发?
  4. 是否可能重复触发?
  5. 字段是否有敏感信息?
  6. 后续如何验证数据是否正确?

十一、前端埋点常见坑

1. SPA 页面 PV 重复或漏报

路由变化、重定向、replaceStatehashchange 都可能影响 PV。不要只监听 pushState

2. 页面关闭导致请求丢失

普通异步请求在页面关闭时可能被取消。离开页面时优先使用 visibilitychange + sendBeacon

3. 曝光事件数据爆炸

曝光、滚动、鼠标移动这类事件必须节流、去重、采样,否则很容易把服务端打爆。

4. 错误日志没有 sourcemap

没有 sourcemap 的错误堆栈只是一堆压缩后的行列号,很难定位源码。

5. 无痕埋点 DOM 路径不稳定

DOM 一改,元素路径就变,数据连续性会被破坏。核心指标不要依赖无痕埋点。

6. 监控 SDK 污染业务逻辑

拦截 fetchXMLHttpRequest、路由、全局错误时必须保持透明,不能改变业务原有行为。

7. 忽略隐私合规

不要上传密码、Token、手机号、身份证、详细地址等敏感信息。必要时做脱敏、白名单和字段过滤。

十二、总结

前端埋点不是简单地“点一下,上报一下”。一个成熟的前端监控 SDK 应该覆盖行为、性能、异常、接口和环境上下文,并且具备标准化、采样、去重、缓存、重试、合规和治理能力。

如果只记住一个结论,那就是:

核心业务数据靠代码埋点保证准确性,普通交互靠声明式埋点降低侵入,无痕埋点只做兜底;采集只是开始,治理才决定这套系统能不能长期用。

真正有价值的前端监控,不是收集最多的数据,而是用最可控的方式收集最有决策价值的数据。