前端监控与埋点这件事,我做了快六年,从最早在页面里手写一行try-catch往接口里塞错误日志,到后来带团队搭整套采集体系,踩过的坑比写过的代码还多。很多人一听“监控埋点”就觉得是运维或者数据团队的事,其实真正落地的时候,前端才是第一现场——用户白屏了、按钮点了没反应、支付流程卡在最后一步,这些问题的第一手证据全在浏览器里。这篇内容就是把前端监控与埋点这套东西从头到尾捋一遍,讲清楚它到底是什么、能解决哪些实际问题、每一层该怎么设计、参数怎么定、坑在哪里。适合刚接触前端监控的新同学照着搭第一版,也适合已经有一套体系但总觉得数据不准、报警太吵的老手对照排查。我不会只给你概念,会把采集原理、上报策略、采样计算、字段设计、常见故障排查都拆开讲,能直接抄作业的地方我会写清楚。
1. 前端监控与埋点的整体设计与选型思路
1.1 先搞清楚监控和埋点到底是不是一回事
很多人把这两个词混着用,实际落地时如果边界不清,最后就是一套四不像的系统。我的理解是:监控偏被动,负责发现“出问题了”;埋点偏主动,负责回答“用户干了什么”。监控关注的是异常、性能、资源加载失败这类客观故障;埋点关注的是行为路径、点击转化、页面停留这类业务动作。两者共用一套上报通道和一套数据规范是最经济的,但采集逻辑必须分开设计,否则字段会互相污染。
举个实际场景:用户反馈“下单按钮点不动”。监控能告诉你这个页面有没有 JS 报错、接口有没有超时;埋点能告诉你用户到底点了几次、点击时按钮是不是处于禁用态、点击前后页面停留了多久。只有两边数据对得上,你才能定位是代码 bug 还是交互设计问题。所以我在设计之初就会把采集层分成两个模块:异常与性能采集模块、行为事件采集模块,它们各自有独立的触发时机和字段结构,最后统一走一个上报队列。
选型上我一般不建议一上来就上大而全的商业方案,先把自建的轻量版跑通,搞清楚自己到底需要哪些指标,再决定要不要接第三方。原因很简单:商业方案字段固定,你想加一个业务自定义维度往往要改配置甚至联系支持,而自建版一个字段十分钟就能加上。等数据量真的上来了、需要做可视化大盘和告警编排了,再考虑迁移或者混合使用。
1.2 整体架构分四层,别想着一步到位
我习惯把整套体系拆成四层来看,这样每一层出问题都好定位:
| 层级 | 职责 | 典型实现 |
|---|---|---|
| 采集层 | 捕获异常、性能、行为事件 | 全局错误监听、Performance API、事件委托 |
| 处理层 | 数据清洗、字段补全、采样过滤 | 统一事件模型、采样器、脱敏处理 |
| 传输层 | 可靠上报、失败重试、批量合并 | sendBeacon、图片打点、队列重试 |
| 消费层 | 存储、聚合、告警、可视化 | 日志服务、指标聚合、告警规则 |
这四层里,最容易出问题的是传输层,因为浏览器环境不可控——页面随时可能被关闭、网络随时可能断。采集层反而相对简单,现代浏览器给的 API 已经够用了。处理层考验的是你对业务字段的理解,消费层则是数据团队的主场。我见过太多团队采集层写得很花哨,结果上报丢了一半,最后数据对不上,白忙一场。
所以我的建议是:先把传输层的可靠性做扎实,再回头优化采集的丰富度。一个能稳定上报 80% 基础数据的系统,远比一个采集了 20 个维度但丢了 40% 数据的系统有价值。
1.3 为什么自建一套轻量方案往往更划算
商业监控平台的优势在于开箱即用、大盘现成、告警规则丰富,但它的短板也很明显:自定义维度受限、数据归属感弱、成本随量线性增长。对于中小型项目,自建一套轻量方案的成本其实很低——一个采集 SDK 核心代码两三百行,一个简单的上报接口,再加一个日志存储,基本就能跑起来。
我算过一笔账:假设日均 PV 十万,每次上报平均 500 字节,一天的数据量大概 50MB,一个月 1.5GB。这个量级用普通的日志服务存储,成本几乎可以忽略。而商业方案按事件数计费,十万 PV 如果每个页面触发三四个事件,一天就是三四十万事件,成本很快就上来了。当然,自建方案需要自己维护,这是隐性成本,团队规模小的时候要权衡。
我的实际做法是混合模式:核心的异常和性能数据自建采集,保证字段可控、成本可控;行为埋点用轻量自建方案,只采集关键转化路径;等业务真的需要深度分析时,再把数据导出到专业分析平台。这样既不会被商业方案绑死,也不会因为自建而失去分析能力。
2. 异常与性能采集的核心细节与实操要点
2.1 全局错误捕获的三个入口一个都不能少
前端异常主要分三类:JS 运行时错误、资源加载错误、未处理的 Promise 拒绝。这三类的捕获方式完全不同,少一个就会漏数据。
JS 运行时错误用window.onerror或者window.addEventListener('error', handler, true)捕获。这里有个关键细节:第三个参数必须传true,也就是在捕获阶段监听,否则资源加载错误捕不到。我早期就踩过这个坑,只写了window.onerror,结果图片 404、脚本加载失败全都没记录,排查线上白屏问题时抓瞎了很久。
资源加载错误比较特殊,它不会冒泡,所以只能在捕获阶段拿到。判断方式是看事件对象的target是不是HTMLScriptElement、HTMLLinkElement、HTMLImageElement这类元素。拿到之后记录资源 URL、标签类型、当前页面路径就够了。
未处理的 Promise 拒绝用window.addEventListener('unhandledrejection', handler)捕获。这里要注意,event.reason可能是 Error 对象,也可能是一个字符串甚至 undefined,做字段补全时要做类型判断,否则序列化的时候会丢信息。
window.addEventListener('error', function (event) { if (event.target && (event.target.src || event.target.href)) { // 资源加载错误 report('resource_error', { url: event.target.src || event.target.href, tag: event.target.tagName, page: location.pathname }); } else { // JS 运行时错误 report('js_error', { message: event.message, filename: event.filename, lineno: event.lineno, colno: event.colno, stack: event.error && event.error.stack, page: location.pathname }); } }, true); window.addEventListener('unhandledrejection', function (event) { var reason = event.reason; report('promise_error', { message: reason && reason.message ? reason.message : String(reason), stack: reason && reason.stack ? reason.stack : '', page: location.pathname }); });注意:
window.onerror和addEventListener('error')不要同时用,会重复上报。选一个就行,我推荐后者,因为能统一处理资源错误。
2.2 性能指标采集要抓住用户真实感受
性能采集最容易犯的错是只采集技术指标,不看用户感受。load时间很短不代表用户觉得快,可能首屏白了好几百毫秒。所以我一般采集这几类指标:
- 导航计时:
performance.timing里的domContentLoaded、loadEventEnd,用来算基础加载耗时。 - 首屏相关:
first-paint、first-contentful-paint,通过PerformanceObserver监听paint类型拿到。 - 最大内容绘制(LCP):监听
largest-contentful-paint,这个指标最能反映用户“看到主要内容”的时间。 - 布局偏移(CLS):监听
layout-shift,累计偏移分数,反映页面抖动程度。 - 交互延迟(INP):监听
event类型的durationThreshold,记录用户交互到下一帧绘制的延迟。
这些指标里,LCP 和 CLS 是我最看重的,因为它们直接对应“页面能不能用”和“页面稳不稳”。采集时要注意,PerformanceObserver的回调是异步的,数据要在页面卸载前通过sendBeacon发出去,否则会丢。
var perfData = {}; new PerformanceObserver(function (list) { var entries = list.getEntries(); var last = entries[entries.length - 1]; perfData.lcp = Math.round(last.startTime); }).observe({ type: 'largest-contentful-paint', buffered: true }); var clsScore = 0; new PerformanceObserver(function (list) { list.getEntries().forEach(function (entry) { if (!entry.hadRecentInput) clsScore += entry.value; }); perfData.cls = Math.round(clsScore * 1000) / 1000; }).observe({ type: 'layout-shift', buffered: true });提示:
buffered: true很关键,它能让观察器拿到注册之前就已经发生的条目,否则首屏指标会漏。
2.3 字段设计决定后期排查效率
采集到的原始数据如果字段设计得不好,后期排查就是灾难。我总结了一套最小字段集,每个事件都必须带上:
| 字段 | 含义 | 为什么必须有 |
|---|---|---|
| event_type | 事件类型 | 区分异常、性能、行为 |
| page_url | 页面路径 | 定位问题页面 |
| timestamp | 发生时间 | 排序和关联 |
| user_id | 用户标识 | 关联同一用户的行为链 |
| session_id | 会话标识 | 还原单次访问 |
| ua | 浏览器信息 | 判断兼容性问题 |
| app_version | 前端版本 | 定位发版引入的问题 |
app_version这个字段我特别强调,很多团队不加,结果发版后错误率飙升却不知道是新版本引入的还是偶发。有了版本号,一对比就能锁定。session_id则用来把同一用户的一次访问串起来,排查“用户说点了没反应”这类问题时,能把点击、报错、接口请求按时间线还原出来。
字段补全的逻辑我一般放在处理层统一做,采集层只负责把原始信息传上来。这样以后要加字段,只改一处就行,不用动所有采集点。
2.4 采样策略要按事件类型区别对待
全量采集在数据量大的时候不现实,但不能一刀切地采样。我的做法是按事件类型分级:
- 异常事件:全量采集,因为异常本身就是低频事件,漏一个都可能错过关键线索。
- 性能事件:按用户采样,比如 10% 的用户全量采集,其余用户只采集 LCP 和 CLS 这两个核心指标。
- 行为埋点:按事件重要性采样,关键转化路径(如下单、支付)全量,普通浏览行为按 20% 采样。
采样率的计算要结合数据量和分析需求。假设日均异常 500 条,全量采集完全没压力;日均行为事件 50 万条,20% 采样就是 10 万条,足够做趋势分析,又不至于存储爆炸。采样时要用用户维度的一致性哈希,保证同一个用户要么一直被采、要么一直不被采,否则行为链会断裂。
function shouldSample(userId, rate) { var hash = 0; for (var i = 0; i < userId.length; i++) { hash = (hash * 31 + userId.charCodeAt(i)) >>> 0; } return (hash % 100) < rate * 100; }这段哈希逻辑很简单,但能保证同一用户采样结果稳定。我试过用随机数采样,结果同一个用户这次被采、下次不被采,行为路径分析完全没法做。
3. 行为埋点的实操过程与关键环节实现
3.1 埋点方式选型:代码埋点、可视化埋点、无埋点怎么选
这三种方式各有适用场景,我一般混着用:
- 代码埋点:在业务代码里显式调用上报函数,字段最精准,适合关键转化路径。缺点是侵入性强,改需求要改代码。
- 可视化埋点:通过配置界面圈选元素,运营自己就能加,适合频繁变化的营销页面。缺点是对动态渲染的元素支持差。
- 无埋点(全埋点):自动采集所有点击和页面浏览,后期再筛选。优点是覆盖全,缺点是数据量大、字段粗糙。
我的实际组合是:核心转化路径用代码埋点保证精度,营销活动页用可视化埋点提升效率,全局用无埋点兜底做行为回溯。三者共用一套事件模型,只是触发方式不同。
无埋点的实现核心是事件委托,在document上监听所有点击,通过event.target往上找带标记的元素。这里要注意,不能把所有点击都上报,否则数据量会失控,一般只上报带>document.addEventListener('click', function (event) { var el = event.target; while (el && el !== document) { if (el.dataset && el.dataset.track) { report('click', { track_id: el.dataset.track, track_name: el.dataset.trackName || '', page: location.pathname, x: event.clientX, y: event.clientY }); break; } el = el.parentNode; } }, true);
注意:用捕获阶段监听(第三个参数
true),避免某些组件阻止冒泡导致埋点丢失。这个坑我在用第三方 UI 库时踩过,按钮内部stopPropagation之后,冒泡阶段的监听完全收不到。
3.2 页面浏览与停留时长怎么算才准
页面浏览(PV)好算,路由变化时上报一次就行。但停留时长是个坑,很多人用beforeunload记录离开时间,结果移动端切后台、浏览器崩溃时数据全丢。
我的做法是心跳 + 可见性监听结合:页面可见时每 15 秒发一次心跳,记录当前时间;visibilitychange变成隐藏时,用当前时间减去最后一次心跳时间,作为这段可见时长的增量。这样即使页面被强制关闭,最多丢 15 秒的数据,比全丢强太多。
var lastHeartbeat = Date.now(); var visibleDuration = 0; setInterval(function () { if (document.visibilityState === 'visible') { var now = Date.now(); visibleDuration += now - lastHeartbeat; lastHeartbeat = now; } }, 15000); document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden') { var now = Date.now(); visibleDuration += now - lastHeartbeat; report('page_stay', { page: location.pathname, duration: Math.round(visibleDuration / 1000) }); visibleDuration = 0; lastHeartbeat = now; } else { lastHeartbeat = Date.now(); } });这里的关键是只统计可见时长,用户切到别的标签页那段时间不算,否则停留时长会虚高,分析用户兴趣时完全失真。我见过有团队直接算离开时间 - 进入时间,结果一个页面平均停留二十分钟,一看就是用户开着页面去吃饭了。
3.3 单页应用的路由监听要特殊处理
单页应用(SPA)的页面切换不触发load事件,所以传统的 PV 统计方式完全失效。我的处理方式是劫持路由变化:如果是 History 模式,重写pushState和replaceState;如果是 Hash 模式,监听hashchange。
function wrapHistory(method) { var original = history[method]; history[method] = function () { var result = original.apply(this, arguments); report('page_view', { page: location.pathname + location.search, referrer: document.referrer }); return result; }; } wrapHistory('pushState'); wrapHistory('replaceState'); window.addEventListener('popstate', function () { report('page_view', { page: location.pathname + location.search, referrer: document.referrer }); });提示:重写原生方法有风险,一定要先保存原方法引用,调用时用
apply保证this正确,否则某些框架的路由会报错。另外popstate也要监听,用户点浏览器后退时不会触发pushState。
3.4 上报通道的可靠性设计
上报通道我一般准备三条路,按优先级降级:
navigator.sendBeacon:页面卸载时首选,浏览器保证发送,不阻塞页面关闭。fetch带keepalive:适合页面存活期间的上报,支持自定义请求头。- 图片打点:
new Image().src = url,兼容性最好,但只能 GET,数据量受限。
批量上报能显著降低请求数,我一般攒够 10 条或者间隔 5 秒就发一次。但要注意,页面卸载时必须立即 flush,不能等攒够,否则数据随页面一起消失。
var queue = []; var MAX_BATCH = 10; var FLUSH_INTERVAL = 5000; function enqueue(event) { queue.push(event); if (queue.length >= MAX_BATCH) flush(); } function flush() { if (!queue.length) return; var payload = JSON.stringify(queue); queue = []; if (navigator.sendBeacon) { navigator.sendBeacon('/api/report', payload); } else { fetch('/api/report', { method: 'POST', body: payload, keepalive: true }); } } setInterval(flush, FLUSH_INTERVAL); window.addEventListener('pagehide', flush);pagehide比beforeunload更可靠,移动端和部分桌面浏览器对beforeunload的支持不稳定,pagehide是更现代的选择。
4. 常见问题与排查技巧实录
4.1 数据对不上:先查采样再查丢失
“埋点数据和业务数据对不上”是最常见的问题。我的排查顺序是:先确认采样率是否一致,再查上报丢失,最后查字段解析。
采样率不一致很隐蔽,比如埋点按 20% 采样,但业务后台是全量统计,两边一比自然差五倍。这种情况要在数据看板上明确标注采样率,或者对采样数据做加权还原。
上报丢失的排查要看网络面板,重点看sendBeacon的返回值。sendBeacon返回false表示浏览器拒绝发送,通常是数据量超过限制(一般 64KB)。我遇到过批量上报攒太多导致超限的情况,后来把批量上限从 50 条降到 10 条就解决了。
字段解析问题常见于时间戳和用户 ID,比如后端把毫秒当秒解析,或者用户 ID 在序列化时被转成了字符串。这类问题只能靠对比原始日志和解析结果来定位。
4.2 报警太吵:用聚合和阈值双管齐下
监控报警最怕“狼来了”,一天几百条报警,最后没人看。我的做法是聚合 + 阈值 + 静默期三重控制:
- 聚合:相同错误信息按分钟聚合,只报一次,附上出现次数。
- 阈值:错误率超过 1% 或者单分钟错误数超过 50 才报警,低于阈值只记录不打扰。
- 静默期:同一个报警规则触发后,30 分钟内不重复报,避免刷屏。
聚合的实现可以在上报接口做,用错误信息的哈希值作为 key,一分钟内的相同错误累加计数。阈值则要结合业务基线来定,新功能上线初期错误率天然偏高,可以临时调高阈值,稳定后再降回来。
4.3 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 错误数据突然归零 | SDK 加载失败或上报接口挂了 | 检查 SDK 脚本是否 200,接口是否超时 |
| 某页面数据缺失 | 该页面路由未触发上报 | 检查路由监听是否覆盖该模式 |
| 停留时长异常偏大 | 未过滤后台时间 | 检查是否用了可见性监听 |
| 点击埋点重复上报 | 事件委托和代码埋点同时触发 | 检查是否有重复监听 |
| 移动端数据偏少 | beforeunload不触发 | 改用pagehide和visibilitychange |
| 错误堆栈为空 | 跨域脚本未加crossorigin | 给脚本标签加crossorigin="anonymous" |
最后这条特别值得说,跨域脚本的错误堆栈默认是拿不到的,浏览器只给Script error.。解决办法是给所有跨域脚本加crossorigin="anonymous",同时服务端返回对应的 CORS 头。这个配置一次到位,后面排查压缩代码的错误就靠它了。
4.4 几个我踩过的坑和对应技巧
第一个坑是SDK 自身报错导致死循环。监控代码自己出错,又触发监控上报,又出错,无限循环。解决办法是在上报函数里加try-catch,并且用一个标志位防止重入。
第二个坑是用户 ID 在登录前后不一致。登录前用匿名 ID,登录后换成真实 ID,导致行为链断裂。我的做法是登录成功后把匿名 ID 作为anonymous_id字段带上,后端做 ID 映射,把两条链合并。
第三个坑是埋点字段名不统一。不同开发写的埋点,有的叫page,有的叫pageUrl,有的叫path,后期分析时字段对不上。解决办法是维护一份字段字典,所有埋点必须按字典来,代码评审时检查。
第四个坑是性能数据被浏览器缓存干扰。第二次访问时资源从缓存加载,性能指标异常好看,但不代表真实体验。我的处理是区分首次访问和重复访问,分别统计,看首次访问的指标更有参考价值。
这套东西搭起来不难,难的是持续维护和迭代。字段会变、业务会变、浏览器环境也会变,监控体系不是一次性的工程,而是需要跟着业务一起长的基础设施。我个人的体会是,与其追求大而全,不如先把异常捕获和核心转化埋点做扎实,跑上两三个月,看看数据到底怎么用,再决定往哪个方向扩展。数据采集本身不是目的,能回答业务问题、能缩短故障排查时间,才是这套体系真正的价值所在。