前端埋点这件事,表面看就是个"用户点了什么就记下来传给后端"的活儿,真动手做起来,坑比想象中多得多。我在三家不同规模的公司从零搭过埋点体系,前两次都翻过车:一次是首页改版后转化率突然腰斩,查了三天才发现是埋点 SDK 在 SPA 路由切换时把页面停留时长算错了;另一次是大促当天上报量暴涨把网关打挂,因为没做采样和限流。所以这篇不打算复述那些"埋点是什么、为什么要埋点"的教科书内容,而是把一套前端埋点 SDK从事件模型、采集层、上报通道到稳定性治理的完整实现路径拆开讲,顺带把那些只有真上线之后才会暴露的细节一并交代清楚。如果你正准备接手数据采集的需求,或者手里已经有一套自研 SDK 但总觉得数据对不上,这篇应该能对上你的胃口。
1. 为什么团队最后都会走上自研埋点 SDK 这条路
几乎每个团队的数据采集需求,起点都是"先接个现成的统计工具吧"。这个选择在业务早期完全正确——接入快、有现成看板、不用运维。但业务一旦进入精细化运营阶段,问题就一个接一个冒出来。
1.1 现成统计工具绕不开的三道坎
第一道坎是数据归属和二次加工。第三方工具给你的是聚合后的结果,原始事件明细往往有保留期限,或者需要通过特定接口才能导出。产品经理想做一次"首单用户的加购路径回溯",发现明细数据拿不到,只能作罢。第二道坎是自定义维度受限。你有一个非常关键的实验分组字段,或者一个业务侧的渠道编码,想作为事件属性带上,结果发现能带的公共参数数量有限,或者字段类型被强约束。第三道坎是私有化与合规要求。有些业务线明确要求用户行为数据不出自己的链路,这时候第三方方案就彻底没戏了。
这三道坎不是所有团队都会同时遇到,但只要遇到其中一道,自研就会提上日程。我个人的判断标准很朴素:当"采集什么、什么时候采、采了发给谁"这三个问题都需要按自己的业务规则定制时,就该自研了。
1.2 自研 SDK 的合理边界在哪
自研最大的风险不是技术难度,而是边界失控。我见过一个团队,SDK 从最初的 8KB 一路膨胀到 90KB,里面塞了自动采集、性能监控、异常捕获、热力图、录屏、A/B 实验分流……最后主站首屏因为加载这个 SDK 慢了 300ms,被业务方投诉到老板那里。
所以动手之前先划边界。我的建议是把 SDK 拆成三层:
| 层级 | 职责 | 是否必需 |
|---|---|---|
| 核心层 | 事件模型、队列、上报、重试、公共参数 | 必需,占体积大头 |
| 采集层 | 手动埋点 API、点击自动采集、路由监听 | 按需,可裁剪 |
| 扩展层 | 性能指标、异常上报、曝光采集 | 独立插件,动态加载 |
核心层的代码量其实很小,压缩后通常不到 5KB。真正让体积失控的是采集层和扩展层,而这些恰恰是可以做成插件、按业务需求动态引入的。先把核心层做稳,再考虑加插件,这个顺序千万别反。
1.3 三种采集方式的取舍逻辑
绕不开的一个选型问题是:代码埋点、可视化埋点、全埋点,到底选哪个。很多文章会说"全埋点最省事",这话只说对了一半。
- 代码埋点:开发者在业务代码里手动调用
track('event_name', { ... })。准确度最高,业务语义最清晰,缺点是侵入业务代码、需要开发配合、改一次埋点要走一次发版。 - 可视化埋点:在页面上用工具圈选元素,配置采集规则,通过配置中心下发。适合运营侧快速加埋点,但对动态渲染、复杂交互的识别能力有限,而且一旦页面结构变更,选择器就容易失效。
- 全埋点:SDK 通过事件代理自动捕获所有点击、页面浏览等行为。覆盖率最高,但数据噪声极大,一个页面几十个可点元素全被记下来,后端存储和清洗成本直线上升。
实操里的常见组合是:核心转化链路用代码埋点保证准确,长尾探索型需求用全埋点兜底。可视化埋点如果是给运营用的,一定要配一套选择器失效的告警机制,否则某天页面改版后你会收到一堆莫名其妙的空数据。
2. 事件模型:一次用户行为到底该抽象成什么结构
埋点 SDK 最核心的设计不是代码写得多优雅,而是事件模型设计得合不合理。模型一旦定死,后面所有采集、上报、存储、分析都围着它转,改起来成本极高。我在第二家公司犯过的最大错误,就是早期把业务参数直接摊平在事件对象顶层,导致后来想加一个同名的业务字段时冲突了。
2.1 一条埋点数据的字段拆解
一条完整的事件记录,我通常拆成四块:
{ // 1. 元信息:由 SDK 自动填充 appId: 'mall_h5', sdkVersion: '1.4.2', eventId: 'evt_8f3a1c9d', // 客户端生成的唯一 ID eventName: 'product_add_cart', // 2. 上下文:描述"谁、在哪、什么时候" userId: 'u_10086', anonymousId: 'anon_7a2f...', sessionId: 'sess_20240612_abc', pageId: 'detail_page', pageUrl: '/product/123', referrer: '/search?kw=shoes', timestamp: 1718164800123, // 3. 业务参数:由调用方传入 properties: { productId: '123', skuId: '123-red-42', price: 299, from: 'recommend_feed' }, // 4. 设备与环境 device: { platform: 'ios', osVersion: '17.4', screen: '390x844', netType: 'wifi' } }这里有几个关键决策值得展开说。
eventId为什么必须由客户端生成?因为上报是会失败的,失败后要重试。如果由服务端生成 ID,重试时就无法做幂等去重,同一条行为会被记两次,转化率直接虚高。客户端生成一个 UUID 或者"时间戳+随机数"的组合,重试时带着同一个 ID,服务端按 ID 去重,这个链路才闭合。
timestamp用客户端时间还是服务端时间?两个都要。客户端时间用于还原用户行为的真实顺序(尤其是跨端、离线补传的场景),服务端接收时间用于做数据分区的依据。但要记住一点:客户端时间不可信,用户手机时间设错、时区不对都会出问题。我会额外传一个clockOffset,记录 SDK 初始化时和服务端对时的差值,分析时用这个差值做校准。
2.2 公共参数和业务参数必须物理隔离
这是我最想强调的一条。把properties单独作为一个对象嵌套,而不是把业务字段平铺到顶层,好处有三个:
其一,公共参数可以统一维护和覆盖。用户从匿名变成登录态时,SDK 只需要更新顶层的userId,所有事件自动带上新身份,业务代码一行都不用改。其二,避免字段名冲突。业务方想传一个timestamp、pageId几乎是必然的,如果平铺就会覆盖 SDK 的元信息,导致数据错乱。嵌套之后,properties.timestamp和顶层timestamp井水不犯河水。其三,上报时可以按需裁剪。比如做性能监控时只需要元信息和上下文,业务参数可以不传,减小报文体积。
我踩过的一个真实坑:早期 SDK 允许业务参数直接平铺,结果某个页面传了userId: null,把 SDK 内部维护的用户身份覆盖成了空。这个 bug 藏了两周才发现,因为数据看起来"有值",只是值不对。嵌套是防呆设计,不是为了好看。
2.3 用户标识、会话与设备指纹的生成策略
身份体系是埋点数据的骨架,没有它,所有数据都是孤立的点,连不成路径。
用户标识分两层:匿名 ID和登录 ID。用户首次访问时,SDK 生成一个匿名 ID 存在localStorage里,之后所有行为都挂在它下面。用户登录后,调用identify(userId),SDK 把登录 ID 也带上。分析侧通过匿名 ID 和登录 ID 的关联,就能把"游客浏览 → 注册 → 下单"这条完整路径串起来。
会话(session)的划分规则主流做法是30 分钟无操作即切新会话。实现上就是在每次事件触发时更新一个lastActiveTime,上报时检查当前时间和它的差值,超时则生成新的sessionId。这里有个移动端的坑:App 切到后台再切回来,时间可能已经过了几小时,但用户主观上觉得"还是刚才那次浏览"。所以我会额外监听页面可见性变化,把切后台那一刻当作会话的实际结束点。
设备指纹就比较敏感了,因为涉及合规。我的做法是只用前端能拿到的低敏感信息做弱指纹:屏幕分辨率、时区、语言、UA 特征哈希,绝不采集任何可用于跨站追踪的强标识。这部分后面讲合规时还会细说。
3. 采集层怎么落地:从事件注册到数据入队
事件模型定好之后,采集层就是把"用户做了什么"翻译成符合模型的事件对象。这一层是出 bug 最多的区域,因为它要和 DOM、路由、浏览器生命周期打交道,而这三样东西的行为在各大浏览器里并不完全一致。
3.1 事件注册与派发的核心骨架
SDK 对外暴露的 API 越简单越好,我一般只留三个方法:
class Tracker { constructor(options) { this.options = Object.assign({ appId: '', reportUrl: '', flushInterval: 5000, // 定时上报间隔 maxBatchSize: 10, // 单批最大条数 maxQueueSize: 50, // 队列上限,超出丢弃最老的 sampling: 1 // 采样率 }, options); this.queue = []; this.userId = null; this.anonymousId = this.getOrCreateAnonymousId(); this.baseProps = this.collectBaseProps(); this.timer = null; this.initTimer(); this.initLifecycle(); } // 手动埋点入口 track(eventName, properties = {}) { if (!this.shouldSample()) return; const event = this.buildEvent(eventName, properties); this.enqueue(event); } // 身份切换 identify(userId) { this.userId = userId; } // 主动刷新队列 flush() { if (!this.queue.length) return; this.send(this.queue.splice(0, this.queue.length)); } }enqueue和send的职责要分清楚:enqueue只管入队和触发阈值检查,send只管把数据发出去。这样拆分的好处是,send可以被重试逻辑复用,而不用重复走一遍入队。
enqueue(event) { this.queue.push(event); if (this.queue.length >= this.options.maxQueueSize) { // 队列溢出,丢弃最老的数据,保证新数据不被阻塞 this.queue.splice(0, this.queue.length - this.options.maxQueueSize); } if (this.queue.length >= this.options.maxBatchSize) { this.flush(); } }注意:队列溢出时丢弃的是最老的数据而不是最新,因为用户最近的行为对分析更有价值。这个策略看似反直觉,但实测下来比"满了就不采"合理得多。
3.2 SPA 路由切换的监听和停留时长计算
单页应用是埋点最容易出错的地方。传统的window.onload在 SPA 里一辈子只触发一次,路由切换根本感知不到。而且很多埋点 SDK 是用setInterval每秒轮询location.href来判断路由变化的,这种做法又慢又耗电,页面切换快了会漏事件。
正确做法是双管齐下。对于 hash 路由,监听hashchange;对于 history 路由,劫持history.pushState和history.replaceState:
function patchHistory(onChange) { const wrap = (type) => { const original = history[type]; history[type] = function (...args) { const result = original.apply(this, args); // 派发自定义事件,统一处理 window.dispatchEvent(new Event('locationchange')); return result; }; }; wrap('pushState'); wrap('replaceState'); window.addEventListener('popstate', () => { window.dispatchEvent(new Event('locationchange')); }); }页面停留时长的计算,逻辑是"上一次页面浏览的开始时间 → 当前页面的开始时间"之差。听起来简单,但有几个细节必须处理:
- 切后台要暂停计时。用户切到微信聊了十分钟再回来,这十分钟不该算进停留时长。监听
visibilitychange,页面隐藏时记录暂停时间点,恢复时把暂停的那段减去。 - 页面卸载时补一次上报。用户直接关掉标签页,最后一次停留时长还没上报,必须在卸载钩子里 flush 一次。
- 停留时长要用
performance.now()而不是Date.now()。系统时间被用户手动调整时,Date.now()会出现负数或巨大的值,performance.now()是单调递增的,天然规避这个问题。
// 页面隐藏时上报当前页面的停留时长 document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { const duration = performance.now() - this.pageStartTime; this.track('page_stay', { duration: Math.round(duration), pageId: this.currentPageId }); this.flush(); } });3.3 全埋点的 DOM 事件代理方案
全埋点看起来很美好,实现起来要克制。核心思路是在document上用捕获阶段监听点击:
document.addEventListener('click', (e) => { const el = e.target.closest('[data-track], button, a, input'); if (!el) return; this.track('auto_click', { tag: el.tagName.toLowerCase(), text: (el.innerText || '').slice(0, 50), path: this.getElementPath(el), xpath: this.getXPath(el) }); }, true);用捕获阶段(第三个参数true)而不是冒泡阶段,是因为很多业务代码会在冒泡阶段stopPropagation(),一旦被拦截,埋点就丢了。捕获阶段优先于冒泡阶段执行,能最大限度地保住事件。
getElementPath生成的是元素的稳定选择器,用来在后端做元素聚合分析。这里有个坑:不要把innerText全量上报。有些页面元素里包含用户昵称、手机号(比如"欢迎你,138****8888"),直接上报就是隐私泄露。截断到 50 字符只是辅助,关键是要有一套敏感信息过滤规则,用正则把手机号、身份证号、邮箱模式替换掉。
另外,全埋点一定要做元素白名单或黑名单。不是所有点击都有分析价值,页面里那些纯装饰性的图标、骨架屏、滚动容器,采了只会污染数据。我通常会给关键元素加>window.addEventListener('error', (e) => { if (e.target !== window && e.target.tagName) { // 资源加载错误,单独处理 this.track('resource_error', { url: e.target.src || e.target.href }); return; } this.track('js_error', { message: e.message, filename: e.filename, lineno: e.lineno, colno: e.colno, stack: e.error && e.error.stack ? e.error.stack.slice(0, 1000) : '' }); });
性能数据用PerformanceObserver拿,比老的performance.timing更准也更灵活:
const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { this.track('perf_metric', { name: entry.name, // 如 LCP、FID、CLS value: Math.round(entry.value || entry.duration), rating: entry.rating }); } }); observer.observe({ entryTypes: ['largest-contentful-paint', 'layout-shift', 'paint'] });提示:性能指标不要每个都采,
paint这类粒度太细的指标上报量极大。我一般只保留 LCP、CLS、INP 三个核心指标,其余的按需开。
4. 上报通道:为什么 sendBeacon 不是万能钥匙
数据采完了,怎么送出去是另一个战场。很多教程一句"用navigator.sendBeacon就完事了",实际用起来你会发现它有不少限制,不搞清楚会踩坑。
4.1 三种上报方式的真实行为差异
| 方式 | 是否阻塞页面卸载 | 能否自定义请求头 | 报文体积限制 | 适用场景 |
|---|---|---|---|---|
XMLHttpRequest同步 | 阻塞,会卡住卸载 | 能 | 无硬限制 | 几乎不该用,体验极差 |
fetch+keepalive | 不阻塞 | 能 | 约 64KB | 卸载时上报、需要自定义头 |
navigator.sendBeacon | 不阻塞 | 不能 | 约 64KB | 卸载时上报、无需自定义头 |
这里的关键差异在于:sendBeacon无法自定义请求头,这意味着你没法在头部带鉴权 token。有些团队的做法是把 token 拼在 URL query 里,但这会让 token 出现在访问日志和 Referer 中,安全性差。而fetch配合keepalive: true既能不阻塞卸载,又能带自定义头,是更现代的选择。
// 优先用 fetch + keepalive,失败降级到 sendBeacon async function beacon(url, data, headers) { const body = JSON.stringify(data); if (navigator.sendBeacon && !headers) { return navigator.sendBeacon(url, new Blob([body], { type: 'application/json' })); } try { await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body, keepalive: true }); return true; } catch (err) { return false; } }sendBeacon发送 Blob 时必须指定Content-Type,否则浏览器会用默认的text/plain,后端解析 JSON 时会出问题。这个细节我看着至少三拨人在联调时卡过。
4.2 队列、批量与定时器的配合
上报策略的核心矛盾是:实时性 vs 请求数量。每条事件都单独发,实时性最好但请求量爆炸;攒一大批再发,请求少了但数据延迟高。
我的默认配置是**"批量阈值 + 定时兜底"**双触发:攒够 10 条立刻发,或者每 5 秒强制发一次,谁先满足用谁。这样正常情况下 5 秒内必有一次上报,突发流量下也不会因为攒太多导致单次请求过大。
initTimer() { this.timer = setInterval(() => { if (this.queue.length > 0) this.flush(); }, this.options.flushInterval); } // 页面隐藏时清掉定时器,避免后台标签页空转 document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { clearInterval(this.timer); } else { this.initTimer(); } });后台标签页的定时器会被浏览器降频到 1 分钟一次,所以切后台时干脆停掉定时器,改成在恢复可见时立即 flush 一次,逻辑更清晰。
4.3 页面卸载时的兜底策略
beforeunload和unload这两个事件在现代浏览器里越来越不可靠——移动端 Safari 经常不触发,Safari 的 bfcache(往返缓存)机制也会跳过这两个事件。所以用visibilitychange判断页面隐藏才是主路径,beforeunload只作为桌面端的补充。
// 主路径 document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') this.flush(); }); // 补充路径,只在桌面端生效 window.addEventListener('beforeunload', () => this.flush());还有一个更棘手的情况:用户点击了站内链接,页面开始跳转。这时候虽然没到卸载,但留给你的时间窗口很短。解决办法是在全局捕获链接点击,在跳转前把数据发出去:
document.addEventListener('click', (e) => { const link = e.target.closest('a[href]'); if (link && !link.target) { this.track('link_click', { href: link.href }); this.flush(); // 跳转前同步触发上报 } }, true);flush()内部用的是异步上报,所以不会阻塞跳转,但因为在同一个事件循环里发起了请求,浏览器会尽量把它发完。
4.4 失败重试和断网续传
网络请求失败是常态,不能失败就算了。重试策略要讲究:
async send(events) { const payload = this.buildPayload(events); const ok = await beacon(this.options.reportUrl, payload, this.headers); if (!ok) { this.saveToStorage(events); // 落本地,等下次重试 return; } this.metrics.successCount += events.length; } saveToStorage(events) { try { const key = `tracker_retry_${this.options.appId}`; const cached = JSON.parse(localStorage.getItem(key) || '[]'); const merged = cached.concat(events).slice(-200); // 最多留 200 条 localStorage.setItem(key, JSON.stringify(merged)); } catch (e) { // localStorage 满了或者被禁用,直接丢弃,不能影响主流程 } }重试要带上退避,不能失败后立刻重发,否则网络确实不通时会把请求量放大。我的做法是下次 flush 时顺带把缓存里的数据一起带上,天然实现了"延迟重试 + 合并上报",不需要额外的退避定时器。
localStorage有可能写满(用户设备上很多应用都在写),所以整个写入过程必须包在try/catch里,埋点失败绝不能影响业务功能。这是原则问题。
5. 传输之外:SDK 的稳定性、体积与合规治理
核心链路跑通只是及格线。真正决定一套埋点 SDK 能不能长期用下去的,是稳定性治理、体积控制和合规这三件事。这三件事做不好,SDK 迟早会被业务方"请"出去。
5.1 采样、限流与脏数据拦截
数据量失控最直接的原因是没做采样。用户量大的产品,全量上报几周就能把存储撑爆。采样的策略要分层:
- 按事件类型采样:核心转化事件(下单、支付)100% 采集,页面浏览、滚动这类高频低价值事件按 10%~30% 采样。
- 按用户采样:用用户 ID 的哈希值对 100 取模,结果小于采样阈值的用户全量采集,其余用户丢弃。这种采样方式的好处是同一个用户要么全采要么全不采,行为路径不会断裂。
- 按会话采样:会话级别的采样粒度更粗,适合做漏斗分析时使用,但单用户的行为序列会不完整。
shouldSample() { if (this.options.sampling >= 1) return true; if (!this.userKey) return Math.random() < this.options.sampling; const hash = this.hashCode(this.userKey) % 100; return hash < this.options.sampling * 100; }限流则是应对异常流量的保险丝。比如某个页面的循环里误调了track,一秒能产生上万条事件。SDK 内部要有一个计数窗口,单页面每秒上报超过阈值就触发熔断,丢弃溢出事件并上报一个告警事件。
脏数据拦截要放在入队之前,检查项包括:事件名是否为空、是否符合命名规范(比如只允许^[a-z][a-z0-9_]{2,49}$)、properties体积是否超过限制(建议单条事件序列化后不超过 8KB)。这些检查开销很小,但能挡掉大量由于开发手误产生的垃圾数据。
5.2 插件化架构和功能裁剪
我又要强调插件化了。核心包只做"事件模型 + 队列 + 上报",其余全部以插件形式挂载:
class Tracker { use(plugin, options) { plugin.install(this, options); this.plugins.push(plugin); return this; // 支持链式调用 } } // 使用 const tracker = new Tracker({ appId: 'xxx', reportUrl: '/collect' }); tracker.use(RouterPlugin).use(ClickPlugin, { whitelist: ['[data-track]'] }).use(ErrorPlugin);插件化的另一个好处是权限可控。某些业务线不允许采集性能数据,那就不加载性能插件,从代码层面保证数据不出这个业务线,比事后在服务端过滤更让人放心。
5.3 体积控制的几个实操手段
前端 SDK 的体积焦虑是真实存在的。我总结了几条实用手段:
- 区分
esm和umd两套产物。现代构建工具能 tree-shaking,把没用的插件摇掉;老的页面用 umd 全量包,但可以通过externals共享公共依赖。 - 状态管理自己撸。SDK 里不要引入任何框架(Vue、React 都不用),几行代码能搞定的事情,别拉进来几十 KB 的依赖。
- 谨慎使用 polyfill。
Object.assign这类方法自己写一个 5 行的替代品,比引入core-js的一整个模块划算得多。 - 构建后做体积基线。每次发版对比 gzip 后体积,涨了 2KB 以上必须说明原因。没有基线约束,体积只会一路膨胀。
我给自己定的红线是:核心包 gzip 后不超过 5KB,带全部常用插件不超过 15KB。超过就要重新审视设计。
5.4 隐私合规不是加个开关就完事
这一块我必须说得直白一点。采集用户行为数据涉及隐私,合规处理要从设计阶段就介入,而不是上线前临时打个补丁。
几条硬性做法:第一,不采集任何明文敏感信息。手机号、身份证号、银行卡号、邮箱,在入队前用正则替换成掩码。这个过滤要放在 SDK 最底层,而不是依赖业务方自觉。第二,提供全局关闭开关。用户拒绝授权时,tracker.disable()要能立即停止所有采集和上报,包括已经入队的数据。第三,采集前明确告知。无论是隐私政策还是首次启动的弹窗,都要说清楚采集了哪些数据、用在哪里。第四,本地缓存要有过期时间。localStorage里的重试队列不能永久保留,加上时间戳,超过 7 天的数据自动清理。
注意:合规是底线问题,不是"优化项"。我在评审 SDK 需求时,隐私相关的条款如果没有明确的方案,这个需求直接打回去重做。
6. 联调与验证:怎么确认埋点真的没丢
代码写完跑通了,不代表埋点是对的。数据采集最坑的地方就在于,出问题时往往已经过了几周,你面对的是"这个数看起来不太对"的模糊反馈。所以验证必须前置,而且要有体系。
6.1 协议约定和时间校准
SDK 和后端之间的采集协议一定要在开发前书面定死:字段名、类型、必填项、时间戳单位(毫秒还是秒,这个最容易出错)、编码格式、压缩方式、幂等键。协议变更要走版本号管理,sdkVersion字段就是用来做这个的,后端可以根据版本做兼容处理。
时间校准我在前面提过,具体实现是在 SDK 初始化时发一个轻量的请求,服务端返回当前时间,SDK 算出差值存起来:
async syncClock() { const start = Date.now(); const res = await fetch('/collect/time'); const end = Date.now(); const serverTime = await res.json(); // 假设网络往返耗时对称,取中间时刻 const rtt = end - start; this.clockOffset = serverTime.timestamp + rtt / 2 - end; }服务端在做数据入库时,用timestamp + clockOffset得到的校准时间做分区,比直接用客户端时间可靠得多。
6.2 本地调试和抓包验证
开发阶段我会给 SDK 加一个调试模式,通过 URL 参数激活:
// ?tracker_debug=1 if (/tracker_debug=1/.test(location.search)) { this.debug = true; }调试模式下,track被调用时会在控制台打印完整事件对象,并额外发一份到本地调试接口(比如http://localhost:3000/debug),开发机上开个简单的服务就能实时看到所有上报,不用等后端环境。这个功能对开发效率的提升非常明显,因为你可以立即看到自己传的参数长什么样,而不是靠猜。
线上排查则依赖抓包。Chrome DevTools 的 Network 面板过滤collect关键字,能直接看到请求体和响应。移动端真机的话,用系统代理配合抓包工具。抓包时重点关注三件事:请求是否发出、请求体格式是否符合协议、响应码是否正常。这三步能定位 90% 的问题。
6.3 上线后的数据质量监控
上线不是终点。我通常会在服务端配一套质量监控指标,每天跑一次:
| 监控指标 | 异常判定 | 可能原因 |
|---|---|---|
| 事件上报总量 | 环比下降超过 30% | SDK 加载失败、上报地址变更 |
| 事件去重率 | 超过 5% | 重试逻辑重复上报、幂等失效 |
| 字段缺失率 | 关键字段缺失超过 1% | 业务方未传参、SDK 版本不一致 |
| 客户端与服务端时间偏差 | 平均值超过 5 分钟 | 时钟校准未生效、设备时间异常 |
| 单事件平均体积 | 突增 50% | 业务参数错误地传入了大对象 |
去重率这个指标特别有用。我在第二家公司就是靠它发现了一次重试风暴——因为网关返回 503,SDK 重试了 5 次,虽然带了幂等 ID,但服务端的去重逻辑有 bug,导致同一条事件被存了多次,转化率虚高了一截。监控去重率,就是监控数据可信度。
6.4 常见问题排查速查
| 现象 | 排查方向 | 处理方案 |
|---|---|---|
| 数据完全收不到 | CDN 加载失败、上报域名被拦截、SDK 初始化报错 | 检查网络面板,确认 SDK 加载顺序在业务代码之前 |
| 部分事件缺失 | 事件在页面卸载后触发、被业务代码stopPropagation | 改用捕获阶段监听、在卸载钩子里 flush |
| 用户身份对不上 | identify调用时机晚于事件上报 | 登录成功后立即调用 identify,并补一次身份关联事件 |
| 停留时长异常大 | 切后台未暂停计时、系统时间被修改 | 用performance.now(),监听可见性变化 |
| SPA 数据翻倍 | 路由监听重复绑定、初始化被多次执行 | 加实例单例保护、路由插件去重绑定 |
| 后端报 JSON 解析失败 | sendBeacon未指定 Content-Type | 用 Blob 包装并显式设置application/json |
7. 我踩过的那些坑,和最后想说的几句话
最后分享几个具体的教训,都是文档里不会写、只有真上线才会遇到的。
第一个是关于SDK 初始化时机。我见过太多团队把 SDK 的script标签放在页面最底部,想着"不阻塞首屏"。结果业务代码在 DOMContentLoaded 里就调了track,此时 SDK 还没加载完,直接报track is not a function,前期数据全丢。正确做法是 SDK 用async提前加载,业务调用要包一层防御:window.tracker && window.tracker.track(...),或者提供一个"命令队列"机制,SDK 没到位时先把调用压入队列,加载完再回放。这个队列机制大概 20 行代码,但能避免大量数据丢失。
第二个是关于埋点命名规范。这事听起来很琐碎,但真的能救命。我接手过一个项目,同一个"加入购物车"行为在不同页面有三个名字:add_cart、addToCart、cart_add。做全局漏斗分析时要写三遍不同的查询,还容易漏。后来我们强制推行了模块名_动作名的下划线命名,比如product_add_cart、order_submit,并且写了个 ESLint 插件在提交时校验。规范的价值不在于整齐,而在于可聚合。
第三个是关于上报失败的静默处理。埋点再怎么重要,也不能因为上报失败让用户的页面卡住。我见过一个实现,send用的是同步 XHR,网络不好时页面直接白屏几秒。这个教训太深刻了:埋点是辅助功能,它的失败必须是静默的、可降级的。所有网络请求、存储操作、DOM 查询,全部包try/catch,出错就丢弃或者缓存,绝不影响业务主流程。
第四个是关于全埋点的数据爆炸。有次我们上线了全埋点插件,第二天后端来找我,说某张表的日增数据从 800 万条涨到了 3 亿条。原因是我们忘了做元素过滤,页面里有个每秒刷新的时间戳元素被反复采集。全埋点上线前必须做灰度,先在小流量上跑一天,看看数据量是否符合预期,再全量放开。
这套 SDK 我前后迭代了大概一年半,从最初的 300 行单文件,到现在的插件化架构,中间推翻重写过两次。最大的心得其实是:别一开始就追求大而全。先把核心的"采、存、发"做扎实,把数据能对上、能串起来,然后再一点点加插件。数据准确性永远排在功能丰富度前面——一个只有三个事件但每个都准的 SDK,远比一个有一百个事件但半数对不上的 SDK 有价值。
如果你现在正准备动手,我的建议是从一个最小的可用版本起步:只做事件模型、内存队列和fetch keepalive上报,跑通一条完整的链路,然后再根据业务反馈决定加什么。至于后面那些采样、限流、插件化、合规治理,等你的 SDK 真的被业务方依赖上了,再逐步补。