系列:前端数据埋点 · 第 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」,对不上「支付按钮点击量」
DSNData 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 时,把 当前整段行为栈 拷进信封。服务端看到的是:

  1. 从 /cart 进了 /checkout
  2. 点了 button#pay
  3. POST /api/pay 返回 500
  4. 然后才是这条脚本错误

行为栈解决复现。统计点击量靠 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 做漏斗、告警,是另一截系统。接入时清单是:

  1. 配 DSN;业务事件和错误的采样分开。
  2. setUser(或等价的 tracker id),否则无法按用户计数。
  3. beforeSend 里删掉隐私字段;不要默认上报请求体和 Cookie。
  4. 上报用没被改过的 fetch;采集听包装后的 API。
  5. ignoreErrors 挡住跨域 Script error、ResizeObserver loop 这类做不了的噪声。

你可以从这里带走什么?

  1. 埋点是四段:采集 → 整形 → 行为栈 → 上报。
  2. 无埋点挡漏;主动埋点给业务语义。点击量靠 EVENT,复现靠行为栈。
  3. 发出去的是信封,不是一句 message。错误和业务事件用类型分流、分别采样。
  4. 上报用没被改过的 fetch,关页时打开 keepalive(告诉浏览器:页面关了也请把这条请求发完)。采集听包装后的 API。两套不能混。
  5. beforeSend 和 ignoreErrors 是默认能力,不是上线后再补。

下一篇:前端数据埋点(2):无埋点——重写原生 API。


仓库与相关文档

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐