系列:前端数据埋点 · 第 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.send、window.fetch、history.pushState,并在捕获阶段监听 click 和 error。业务照常写 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 的函数只负责截获 ,不负责「这是错误还是点击、要不要上报」。截获之后触发一类事件名,例如 xhr、fetch、error、dom。监听方按类型注册回调:
js
subscribe('xhr', (httpData) => {
// 这里才决定:推进行为栈,还是当成 HTTP 错误上报
})
同一类事件可以挂多个回调,采集侧不用知道后面有几个人在听。这是整条管线能拆开演进的关键:换一种上报方式,不必再改 XHR 重写逻辑。
3.3 整形:原生对象不能直接发走
ErrorEvent、XMLHttpRequest 带一堆浏览器私有字段,后端没法按「同一种错误」聚合。整形就是收成一张固定表。
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 只补 id、actionType、trackTime。
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()------才调用 send。send 会把当前整段行为栈拷进信封。于是服务端拿到的不是孤零零一条 error,而是:
- 从
/cart进了/checkout - 点了「立即支付」
POST /api/pay返回 500- 然后才是这次脚本错误
行为栈解决的是复现,不是统计。统计点击量仍然要靠主动埋点的 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 | 成功则只进行为栈;失败则再 send 到 dsn |
失败要告警 |
| 随后脚本抛错 | window.onerror |
dsn,信封里带上上面那些面包屑 |
排查 |
如果只做无埋点、不做 track('EVENT'),你能知道「有人点了某个 button」,对不上产品说的「支付按钮点击量」。如果只做 track()、没有行为栈,错误上报仍然没法复现。两条线要一起看。
5. 上报之后:流程在浏览器这边结束
浏览器(或小程序)把信封 POST 到 DSN 之后,SDK 的工作就结束了。服务端通常会:落库、按 errorId 聚合、按 trackId 做漏斗、必要时告警。那是另一截系统,不改变采集侧这四段。
接入时只要记住三件事:
- 没配
dsn,错误发不出去;没配trackDsn,主动埋点发不出去。 backTrackerId不配,后端就无法回答「影响了多少用户」。- 上报 URL 必须能被采集层识别并跳过,否则无埋点会吃到自己。
你可以从这里带走什么?
- 埋点是四段管线:采集 → 整形 → 行为栈 → 上报。缺任何一段,不是漏事件,就是没法复现,就是把自己打进死循环。
- 无埋点解决「漏」;主动埋点解决「没有业务语义」。点击统计靠
EVENT,出错复现靠行为栈,不要用其中一条冒充另一条。 send用有没有actionType选择trackDsn还是dsn。分析数据和稳定性数据从这一刻分开。- 真正发出去的是信封:身份、行为栈、本次
data、设备信息。只发一句 message,后端聚合不了。 - 上报必须排队,且不能被自己的采集再次截获。
下一篇(前端数据埋点 · 2)写无埋点本身:怎样重写 XHR / fetch / error / click,以及为什么必须跳过 SDK 自己的上报地址。