前端数据埋点(1):一次点击如何变成一条埋点
系列:前端数据埋点 · 第 1 篇
下一篇:无埋点——重写原生 API
先说结论
埋点是把「页面上发生过什么」变成结构化记录,并送到服务端的过程。它不是
console.log,也不是在业务函数里随手 POST 一段字符串。
一条事件走四段:
产生(点击 / 报错 / 业务调用 track)
→ 采集(无埋点截获,或主动传入)
→ 整形 + 行为栈(固定字段;近期轨迹先记下)
→ 上报(打包、抽样、去掉隐私、再发 HTTP)
| 段 | 做什么 |
|---|---|
| 采集 | 无埋点重写 XHR / fetch / error / click / history;主动埋点调用 track() |
| 整形 | 原生对象收成可聚合字段(type、url、status、message…) |
| 行为栈 | 点击、路由、成功的请求先记在本地队列;真正上报时整段附上 |
| 上报 | 错误和业务事件分开;发出前可改可丢;发 HTTP 时用浏览器没被改过的 fetch,并告诉浏览器「关页也请发完」 |
无埋点拿到的是技术事件(一次 XHR、一次脚本错误)。主动埋点上报的是业务事件(一次支付点击)。两条线不要互相冒充。
1. 埋点要挡住什么
结算按钮既要统计点击,出错时还要能复现。如果只在业务里写:
payButton.addEventListener('click', () => {
fetch('/log', { method: 'POST', body: JSON.stringify({ event: 'pay_click' }) })
doPay()
})
会同时漏掉三件事。
漏。 路由切换、脚本报错、图片加载失败、接口 500,不可能每个调用点都手写一行。漏掉的那些,恰恰是故障时最需要的。
脏。 上报自己也是 HTTP。采集层若听的是包装后的 fetch / XHR,又把 SDK 自己的 POST 再采一遍,就会上报 → 再采集 → 再上报。
没法复现。 服务端只收到一句 Cannot read property x of undefined,不知道上一秒点了哪个按钮、从哪页来、刚才那次接口是不是已经 500。
所以采集要覆盖共享的原生层,上报要躲开自己的包装,每条需要人看的记录都要带得上前文。
2. 概念词典
每个词第一次出现时只记一件事:它解决什么问题。英文名字可以后对。
| 词 | 是什么 | 解决什么 |
|---|---|---|
| 无埋点 | SDK 在 init 时改浏览器自带的 XHR、fetch、点击、路由,业务代码不用写采集 | 漏采:报错和跳转不可能每个地方都手写一行 |
| 主动埋点 | 业务自己调用 track('EVENT', { trackId: 'pay_click' }),带上产品名 | 无埋点只知道「点了某个 button」,对不上「支付按钮点击量」 |
| DSN | Data Source Name。就是上报地址,类似「数据打到哪个收集接口」 | 没配这个地址,浏览器不知道往哪发 |
| 行为栈 | 最近发生的点击、跳转、请求,先存在内存里的短队列(Breadcrumb) | 出错时能看到「他刚才做了什么」,而不只是一句 error |
| 整形 | 把浏览器的 ErrorEvent、XHR 对象收成后端能分组的字段 | 原样 JSON.stringify 出去,同类错误聚合不了 |
| 信封 | 一次 POST 的整包内容:谁发的、用户是谁、行为栈、这条事件本身(有的 SDK 叫 Envelope) | 只发一句 message,后端无法区分用户和版本 |
| 采样 | 一个 0~1 的比例。1 表示全发,0.1 表示大约十次里发一次(sampleRate) | 高流量时把收集服务打满 |
| 发出前钩子 | 数据包离开浏览器前的最后一个函数,可改字段或返回空表示不发(beforeSend) | 密码、token、请求体进仓库 |
主动埋点用 actionType 区分业务含义:
| actionType | 含义 | 典型场景 |
|---|---|---|
PAGE | 页面曝光 | 进入结算页 |
EVENT | 事件 | 点击「立即支付」 |
VIEW | 区域曝光 | 优惠券模块出现在视口 |
DURATION | 时长 | 停留在结果页 12s |
DURATION_VIEW | 区域停留时长 | 某模块在视口内停留 3s |
报错也可以主动调用,例如 captureException(error):走同一套管线,只是这条记录的类型是错误,不是 EVENT。
3. 管线:一条事件怎么走完
init 时就要把入口、采样、过滤、脱敏一次配齐:
init({
dsn: 'https://ingest.example.com/0', // 上报地址
sampleRate: 1.0, // 错误全发;业务点击通常再配一个更低的比例
maxBreadcrumbs: 100, // 行为栈最多留多少条;整包还有体积上限
ignoreErrors: [
/^Script error\.?$/, // 跨域脚本报错,浏览器给不出有用栈
/ResizeObserver loop/, // 布局观察回调偶发,一般不可做
],
beforeSend(event) {
// 发出前删掉请求体、Authorization、表单里的密码
return event
},
})
setUser({ id: currentUserId }) 把当前用户写进后续每条信封。不设的话,后端无法回答「这个错误影响了多少人」,只能按匿名会话估。
3.1 采集
两条入口同时存在。
无埋点。 SDK 给 XHR / fetch / history / click / window.onerror 包一层或绑监听。每类 API 只包装一次;「记进行为栈」和「当成 HTTP 错误发出」是两个回调,挂在同一类事件上。业务照常 fetch('/api/pay'),包装层记下 method、url、开始时间,结束时再补 status、耗时。
主动埋点。 关键路径上调用:
track('EVENT', {
trackId: 'pay_click',
custom: { orderId: 'A1001', amount: 99 },
})
SDK 补上事件 id 和时间,交给和错误共用的发送管线。错误还是业务事件,用记录上的类型字段区分。
点击「立即支付」时两条线一起动:无埋点把 DOM 路径推进行为栈(出错时用来复现),track('EVENT') 记转化。前者不是点击量。
3.2 订阅:截获和处置分开
包装函数只 trigger('xhr', data)。听的人决定进栈还是当成错误发出去:
addHandler('xhr', (httpData) => {
// 成功:进行为栈
// 失败:整形后 capture / send
})
同一类可以挂多个回调。换处置逻辑不必再改 XMLHttpRequest。回调必须 try/catch:包装跑在业务调用栈上,采集抛错会砸掉支付请求。
3.3 整形
ErrorEvent、XMLHttpRequest 不能原样 JSON 出去。HTTP 失败收成固定表,例如:
{
type: 'http',
url: 'https://shop.example.com/checkout',
request: { method: 'POST', url: '/api/pay' },
response: { status: 500 },
elapsedTime: 230,
}
脚本错误带 message + 解析后的 stack。资源加载失败带标签名和 src。fetch 第一参可能是 Request 对象,method/url 要从对象上取,不能当成 string。
请求体、Cookie、Vue/React 的 props 默认不要进信封。要带也经过 beforeSend 白名单。登录接口的 password 进行为栈是事故,不是功能。
主动埋点几乎不用再整形:trackId / custom 本身就是分析字段。
3.4 行为栈
点击、路由、成功的 XHR 默认 不上报,推进有上限的队列(常见默认 100,还要受整包 1MB 一类载荷限制)。满了丢掉最老的。beforeBreadcrumb 返回 null 则本条不进栈。
addBreadcrumb({
category: 'ui.click',
message: 'body > form > button#pay',
level: 'info',
})
脚本抛错、接口失败、track() / captureException 时,把 当前整段行为栈 拷进信封。服务端看到的是:
- 从
/cart进了/checkout - 点了
button#pay POST /api/pay返回 500- 然后才是这条脚本错误
行为栈解决复现。统计点击量靠 EVENT。
3.5 上报
发出去之前依次经过:采样(没抽中就丢)→ ignoreErrors(匹配到的错误直接忽略)→ 同类错误去重 → beforeSend。避免一个死循环把收集接口打爆。
信封里至少有:SDK 名称与版本、用户 id、行为栈、本次事件、当前页面地址。错误和业务事件用类型区分,采样比例分开配。
真正发 HTTP 的那一层(Transport)要注意两件事。
第一,不要用页面上那个已经被包过的 fetch。 无埋点改的是 window.fetch。如果 SDK 自己的上报也走它,采集层会把「上报」再当成一次业务请求,死循环。所以上报要调用 没被改过的那份 fetch(从浏览器原生实现里取出、缓存下来)。页面业务和无埋点继续用包装后的 window.fetch。
第二,关页时请求别被浏览器取消。 用户点完支付立刻跳走,普通 XHR / 普通 fetch 会随页面一起停掉,最后一条埋点就丢了,漏斗会系统性偏低。
keepalive 是 fetch 的一个选项,默认是关的。打开之后,意思是:文档已经在卸载,这个请求请仍然发完。 浏览器给它的预算很小(规范大约 64KB 在途、并发也有限),所以实现里会自己再卡一档,超了就不当 keepalive 发,避免被浏览器直接拒绝。
识别「这是 SDK 自己的上报」还有第二道闸:回调里看到上报地址(或地址上约定的 key)就不要再进行为栈。
4. 一次「支付点击」在管线里
同一秒里可以有多份数据,职责不同。
| 时刻 | 谁采集 | 去向 | 作用 |
|---|---|---|---|
| 进入结算页 | track('PAGE', { trackId: 'checkout' }) | 业务事件 | 统计曝光 |
| 点击按钮 | 无埋点 click | 只进行为栈 | 给出错当前文 |
| 点击按钮 | track('EVENT', { trackId: 'pay_click' }) | 业务事件 | 统计转化 |
POST /api/pay 结束 | 无埋点 XHR / fetch | 成功只进栈;失败再发错误事件 | 告警 |
| 随后脚本抛错 | onerror / captureException | 错误事件,信封带上行为栈 | 排查 |
缺 EVENT,你只知道点了某个 button,对不上产品指标。缺行为栈,错误仍然没法复现。
5. 浏览器这边的工作在信封发出后结束
服务端落库、把相同错误合成一条、按 trackId 做漏斗、告警,是另一截系统。接入时清单是:
- 配 DSN;业务事件和错误的采样分开。
setUser(或等价的 tracker id),否则无法按用户计数。beforeSend里删掉隐私字段;不要默认上报请求体和 Cookie。- 上报用没被改过的
fetch;采集听包装后的 API。 ignoreErrors挡住跨域 Script error、ResizeObserver loop这类做不了的噪声。
你可以从这里带走什么?
- 埋点是四段:采集 → 整形 → 行为栈 → 上报。
- 无埋点挡漏;主动埋点给业务语义。点击量靠
EVENT,复现靠行为栈。 - 发出去的是信封,不是一句 message。错误和业务事件用类型分流、分别采样。
- 上报用没被改过的
fetch,关页时打开keepalive(告诉浏览器:页面关了也请把这条请求发完)。采集听包装后的 API。两套不能混。 beforeSend和ignoreErrors是默认能力,不是上线后再补。
仓库与相关文档
- 文章参考:sentry-javascript
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)