前端数据埋点(1):一次点击如何变成一条埋点

系列:前端数据埋点 · 第 1 篇

下一篇:无埋点------重写原生 API 之后,业务代码不用自己打点

读者会写 JavaScript。本篇把埋点当成一条数据管线来讲:事件从页面里产生,经过哪几层,最后怎样离开浏览器。不预设你做过监控 SDK。


先说结论

步骤 业务里自己打点 埋点 SDK 的做法
采集 每个按钮、每个接口失败处手写 fetch('/log') 无埋点:重写 XHR / fetch / error / click;主动埋点:调用 track()
携带上下文 常常只有「出错了」这一句 上报时附带行为栈(用户刚才点了什么、走过哪条路由)
通道 错误、点击、曝光挤在一个 URL 错误走 dsn,业务埋点走 trackDsn
何时发出 在当前调用栈里立刻请求 推进微任务队列,避免卡住页面、也避免上报请求被自己再次采集

埋点 是把「页面上发生过什么」变成结构化记录,并送到服务端的过程。它不是 console.log,也不是随便 POST 一段字符串。

一次完整走法只有四段:

复制代码
产生(点击 / 报错 / 业务调用 track)
  → 采集(截获或手动传入)
  → 整形 + 行为栈(变成可分析的字段;必要时先记下用户轨迹)
  → 上报(选通道、装信封、排队发出)

1. 缺口:为什么不能到处 fetch('/log')

假设结算按钮要统计点击。最直接的写法是:

js 复制代码
payButton.addEventListener('click', () => {
  fetch('/log', { method: 'POST', body: JSON.stringify({ event: 'pay_click' }) })
  doPay()
})

这能跑,但很快会卡在三件事上。

漏。 路由切换、脚本报错、图片加载失败、接口 500,不可能每个地方都记得写一行。漏掉的那些,恰恰是出故障时最需要的。

脏。 上报自己也是一次 HTTP 请求。如果你在监听所有 XHR,却不过滤上报地址,SDK 会采集到自己的上报,再上报,再采集------死循环。

没法复现。 服务端只收到 { message: 'Cannot read property x of undefined' }。不知道用户上一秒点了哪个按钮、从哪页跳过来、刚才那次接口是不是已经 500 了。一条错误没有前文,排查只能靠猜。

所以埋点要解决的不是「把字发出去」,而是:采集要全、上报要干净、每条记录要带得上上下文。


2. 概念词典

下面这些词会贯穿整个系列。每个词只回答一件事:它挡住了上面哪一种缺口。

是什么 相对什么 挡住哪类缺口
无埋点 重写浏览器原生 API(XHR、fetch、window.onerror、点击、history),业务代码不用写采集 相对「每个事件手写一行」
主动埋点 业务显式调用 track(actionType, param),带上业务语义 相对无埋点(无埋点不知道「这是结算按钮」) 没有业务含义
DSN Data Source Name,上报地址。错误一条,埋点一条 相对「所有日志打到同一个 URL」 后端无法分流
行为栈 / Breadcrumb 固定长度的近期事件队列,出错时整段附在上报里 相对「只报当前这一条」 没法复现
整形 把原生事件对象收成固定字段(type、url、status、message...) 相对把 ErrorEvent 原样 JSON.stringify 后端无法聚合
信封 真正 POST 出去的那一包:身份 + 行为栈 + 本次数据 + 设备信息 相对只发 data 本身 无法区分用户、版本、机型

actionType 是主动埋点的类型枚举,常见五种:

actionType 含义 典型场景
PAGE 页面曝光 进入结算页
EVENT 事件 点击「立即支付」
VIEW 区域曝光 优惠券模块出现在视口
DURATION 时长 停留在结果页 12s
DURATION_VIEW 区域停留时长 某模块在视口内停留 3s

无埋点截获的是技术事件 (一次 XHR、一次脚本错误)。主动埋点上报的是业务事件 (一次支付点击)。两条线最后进同一个 send(),用「有没有 actionType」决定走哪条 DSN。


3. 管线:一条事件怎么走完

初始化时要先告诉 SDK 两个地址,以及「这是哪个应用」:

js 复制代码
init({
  dsn: 'https://monitor.example.com/errors/upload',   // 错误 / 异常
  trackDsn: 'https://monitor.example.com/track/upload', // 业务埋点
  apikey: 'app-web-001',
  trackKey: 'app-web-001-track',
  maxBreadcrumbs: 10,
  backTrackerId: () => currentUserId, // 用来统计「这个错误影响了多少用户」
})

之后发生的事,按时间顺序是这样。

3.1 采集:事件从哪进来

两条入口,不要混。

无埋点。 SDK 在 init 时重写 XMLHttpRequest.prototype.sendwindow.fetchhistory.pushState,并在捕获阶段监听 clickerror。业务照常写 xhr.send(),SDK 在原函数前后塞入自己的逻辑:记下 method、url、开始时间,等 loadend 再算出耗时和 status。

主动埋点。 业务在关键路径上调用:

js 复制代码
track('EVENT', {
  trackId: 'pay_click',
  custom: { orderId: 'A1001', amount: 99 },
})

SDK 补上 id(一条记录的 uuid)和 trackTime,然后交给 send

点击「立即支付」时,两条线可能同时动:无埋点记下一次 DOM click(进行为栈),主动埋点再报一条 EVENT(真正给分析用)。前者回答「出错前他点了什么」,后者回答「有多少人点了支付」。

3.2 订阅:采集和处置要分开

重写原生 API 的函数只负责截获 ,不负责「这是错误还是点击、要不要上报」。截获之后触发一类事件名,例如 xhrfetcherrordom。监听方按类型注册回调:

js 复制代码
subscribe('xhr', (httpData) => {
  // 这里才决定:推进行为栈,还是当成 HTTP 错误上报
})

同一类事件可以挂多个回调,采集侧不用知道后面有几个人在听。这是整条管线能拆开演进的关键:换一种上报方式,不必再改 XHR 重写逻辑。

3.3 整形:原生对象不能直接发走

ErrorEventXMLHttpRequest 带一堆浏览器私有字段,后端没法按「同一种错误」聚合。整形就是收成一张固定表。

HTTP 失败大致变成:

js 复制代码
{
  type: 'HTTP_ERROR',
  url: 'https://shop.example.com/checkout',  // 当时的页面
  name: 'xhr--POST',
  message: 'internal_error /api/pay',
  elapsedTime: 230,
  request: { method: 'POST', url: '/api/pay', data: '...' },
  response: { status: 500, data: '...' },
}

脚本错误变成 JAVASCRIPT_ERROR + message + stack。资源加载失败变成 RESOURCE_ERROR + 标签名 + src。

主动埋点几乎不用再整形,业务传入的 trackId / custom 本身就是分析字段;SDK 只补 idactionTypetrackTime

3.4 行为栈:先记下,不一定立刻上报

点击、路由切换、成功的 XHR,默认不上报,只推进一个长度有上限的队列(常见默认 10 条)。新事件进来、队列满了,丢掉最老的一条。

js 复制代码
breadcrumb.push({
  type: 'Click',
  category: 'user',      // http | user | debug | exception | lifecycle
  data: '<button id="pay">立即支付</button>',
  level: 'info',
  time: Date.now(),
})

只有「需要人来看」的时刻------脚本抛错、接口失败、主动 track()------才调用 sendsend 会把当前整段行为栈拷进信封。于是服务端拿到的不是孤零零一条 error,而是:

  1. /cart 进了 /checkout
  2. 点了「立即支付」
  3. POST /api/pay 返回 500
  4. 然后才是这次脚本错误

行为栈解决的是复现,不是统计。统计点击量仍然要靠主动埋点的 EVENT

3.5 上报:选通道、装信封、排队

send 只看一件事:这份 data 有没有 actionType

js 复制代码
async function send(data) {
  const dsn = data.actionType ? trackDsn : errorDsn
  if (!dsn) return

  const packet = {
    authInfo: {
      trackerId: String(backTrackerId()),
      apikey,
      trackKey,
      sdkName,
      sdkVersion,
    },
    breadcrumb: breadcrumb.getStack(),
    data,
    deviceInfo: { netType, clientWidth, clientHeight, ratio },
  }

  // beforeDataReport 可以改包、也可以返回空以取消本次上报
  queue.addFn(() => xhrPost(packet, dsn))
}

双 DSN 让错误监控和产品分析不必抢同一个接口、同一套存储。

信封 让后端先读 authInfo 知道是谁、哪个应用,再读 data 知道发生了什么,最后用 breadcrumb 复现过程。

队列 是微任务批量:send 并不在当前调用栈里立刻 xhr.send。原因有二:上报不应拖慢正在执行的业务;上报本身是 HTTP,必须标成「这是 SDK 自己的请求」,采集层看到上报 URL 就跳过,否则会死循环。

浏览器里默认用 XHR POST JSON;也可以改成 Image 打点(new Image().src = url + '?data=' + encodeURIComponent(...)),优点是不吃跨域预检,缺点是长度有限。小程序则走 wx.request

发出去之前还有两道闸:beforeDataReport 改包或取消;错误还有 errorId 去重 ------同类错误(同一个 type + message + apikey)超过 maxDuplicateCount 就不再发,避免一个死循环把上报打爆。


4. 对照:一次「支付点击」在管线里的位置

把时间摊开,同一秒里可能有三份数据,职责不同。

时刻 谁采集 进哪 作用
进入结算页 track('PAGE', { trackId: 'checkout' }) trackDsn 统计曝光
点击按钮 无埋点监听 click 只进行为栈 给稍后的错误当前文
点击按钮 track('EVENT', { trackId: 'pay_click' }) trackDsn 统计转化
POST /api/pay 结束 无埋点重写了 XHR 成功则只进行为栈;失败则再 senddsn 失败要告警
随后脚本抛错 window.onerror dsn,信封里带上上面那些面包屑 排查

如果只做无埋点、不做 track('EVENT'),你能知道「有人点了某个 button」,对不上产品说的「支付按钮点击量」。如果只做 track()、没有行为栈,错误上报仍然没法复现。两条线要一起看。


5. 上报之后:流程在浏览器这边结束

浏览器(或小程序)把信封 POST 到 DSN 之后,SDK 的工作就结束了。服务端通常会:落库、按 errorId 聚合、按 trackId 做漏斗、必要时告警。那是另一截系统,不改变采集侧这四段。

接入时只要记住三件事:

  1. 没配 dsn,错误发不出去;没配 trackDsn,主动埋点发不出去。
  2. backTrackerId 不配,后端就无法回答「影响了多少用户」。
  3. 上报 URL 必须能被采集层识别并跳过,否则无埋点会吃到自己。

你可以从这里带走什么?

  1. 埋点是四段管线:采集 → 整形 → 行为栈 → 上报。缺任何一段,不是漏事件,就是没法复现,就是把自己打进死循环。
  2. 无埋点解决「漏」;主动埋点解决「没有业务语义」。点击统计靠 EVENT,出错复现靠行为栈,不要用其中一条冒充另一条。
  3. send 用有没有 actionType 选择 trackDsn 还是 dsn。分析数据和稳定性数据从这一刻分开。
  4. 真正发出去的是信封:身份、行为栈、本次 data、设备信息。只发一句 message,后端聚合不了。
  5. 上报必须排队,且不能被自己的采集再次截获。

下一篇(前端数据埋点 · 2)写无埋点本身:怎样重写 XHR / fetch / error / click,以及为什么必须跳过 SDK 自己的上报地址。

相关推荐
IT_陈寒1 小时前
Python线程池吞了异常还不告诉我,这谁顶得住啊
前端·人工智能·后端
计算机魔术师1 小时前
同样是AI辅助建造,为什么有的游戏三个月就烂尾了?
前端
用户921080262861 小时前
0. 做 Agent 产品,前端 AI 组件框架怎么选?
前端
拾年2752 小时前
React Hooks 进阶篇:useMemo / useCallback / useReducer / useImperativeHandle,用「算账 +
前端·javascript·react.js
Jackson__2 小时前
面试官:如何在老项目中使用新框架,并实现通信?
前端·javascript·面试
拾年2752 小时前
React Hooks 保姆级教程:把组件想象成失忆的打工人,4 个 Hook 带你原地起飞
前端·javascript·react.js
mmsx3 小时前
一个 Bug 修了 12 轮,根因只有一句话:AI 编程时代,日志才是唯一的真相
前端
默_笙3 小时前
😝 5 亿次循环卡死页面?我用 Web Worker 把它扔进后台线程,页面丝滑如初
前端·javascript
CAD老兵3 小时前
一行代码集成 DWG/DXF 图纸查看:测量批注,数据不出站
前端·javascript·github