如何设计前端异常监控和埋点体系?
按“体系设计 → 异常监控 → 行为埋点 → 上报链路 → Vue 项目落地”来讲。重点放在前端 Vue 项目里真正能用、能维护、能排查问题的方案。一、整体目标前端异常监控和埋点体系本质上解决两类问题用户出了什么问题用户做了什么行为异常监控偏稳定性JS 报错Promise 未捕获异常Vue 组件异常资源加载失败接口异常白屏页面性能异常路由跳转异常业务异常行为埋点偏业务分析页面访问按钮点击表单提交搜索转化漏斗用户路径停留时长曝光自定义业务事件一个完整体系通常包含前端 SDK ↓ 数据采集 ↓ 数据清洗 / 脱敏 / 采样 ↓ 队列缓存 ↓ 批量上报 ↓ 服务端接收 ↓ 存储 / 聚合 / 告警 / 可视化二、前端异常监控体系设计1. 需要采集哪些异常1. JS 运行时异常典型错误const a undefined a.name通过window.onerror或window.addEventListener(error)捕获。window.addEventListener(error, event { console.log(JS error:, event.message) })需要采集字段{ type: js_error, message: event.message, filename: event.filename, lineno: event.lineno, colno: event.colno, stack: event.error?.stack, time: Date.now() }2. Promise 未捕获异常例如Promise.reject(new Error(接口异常))通过window.addEventListener(unhandledrejection, event { console.log(Promise error:, event.reason) })采集字段{ type: promise_error, message: event.reason?.message || String(event.reason), stack: event.reason?.stack, time: Date.now() }3. Vue 组件异常Vue 2Vue.config.errorHandler function (err, vm, info) { console.log(err, vm, info) }Vue 3app.config.errorHandler (err, instance, info) { console.log(err, instance, info) }采集字段{ type: vue_error, message: err.message, stack: err.stack, component: instance?.type?.name, info, time: Date.now() }Vue 异常监控很重要因为很多组件渲染、生命周期、事件处理中的错误不一定容易从普通 JS 错误里还原业务上下文。4. 资源加载失败比如JS 文件加载失败CSS 加载失败图片加载失败字体加载失败捕获方式window.addEventListener( error, event { const target event.target if (target (target.src || target.href)) { console.log(Resource error:, target.src || target.href) } }, true )注意第三个参数要用true资源加载错误不会冒泡需要捕获阶段监听。采集字段{ type: resource_error, tagName: target.tagName, url: target.src || target.href, html: target.outerHTML, time: Date.now() }5. 接口异常接口异常一般通过封装fetch或axios拦截器采集。比如 axiosaxios.interceptors.response.use( response response, error { report({ type: api_error, url: error.config?.url, method: error.config?.method, status: error.response?.status, message: error.message, response: error.response?.data, duration: Date.now() - error.config?.metadata?.startTime }) return Promise.reject(error) } )请求开始时记录时间axios.interceptors.request.use(config { config.metadata { startTime: Date.now() } return config })接口监控建议区分HTTP 状态码异常404、500、502、504 业务状态码异常code ! 0 网络异常timeout、Network Error 慢请求duration 阈值6. 白屏监控白屏不是简单的 JS 报错而是用户看到页面不可用。常见判断方式页面加载一段时间后检查关键 DOM 是否存在检查页面中心点是否还是空节点检查根节点内容高度检查关键业务容器是否渲染完成示例function checkWhiteScreen() { const root document.querySelector(#app) if (!root || root.children.length 0) { report({ type: white_screen, message: #app has no children, url: location.href, time: Date.now() }) } } setTimeout(checkWhiteScreen, 3000)更进一步可以用采样点function isWhiteScreen() { const points [ [window.innerWidth / 2, window.innerHeight / 2], [window.innerWidth / 4, window.innerHeight / 4], [window.innerWidth * 3 / 4, window.innerHeight / 4], [window.innerWidth / 4, window.innerHeight * 3 / 4], [window.innerWidth * 3 / 4, window.innerHeight * 3 / 4] ] const emptyTags [HTML, BODY, DIV] let emptyCount 0 points.forEach(([x, y]) { const el document.elementFromPoint(x, y) if (el emptyTags.includes(el.tagName)) { emptyCount } }) return emptyCount 4 }Vue 项目里更推荐结合路由和首屏组件状态router.afterEach(to { setTimeout(() { const app document.querySelector(#app) if (!app || app.innerText.trim().length 0) { report({ type: route_white_screen, path: to.fullPath }) } }, 3000) })7. 性能监控可以采集FPFirst PaintFCPFirst Contentful PaintLCPLargest Contentful PaintCLSCumulative Layout ShiftFID / INP交互延迟TTFB首字节时间DOMContentLoadedload首屏渲染时间路由切换耗时接口耗时使用 Performance APIwindow.addEventListener(load, () { setTimeout(() { const navigation performance.getEntriesByType(navigation)[0] report({ type: performance, dns: navigation.domainLookupEnd - navigation.domainLookupStart, tcp: navigation.connectEnd - navigation.connectStart, ttfb: navigation.responseStart - navigation.requestStart, domReady: navigation.domContentLoadedEventEnd - navigation.startTime, load: navigation.loadEventEnd - navigation.startTime }) }) })Web Vitals 推荐使用官方库npm install web-vitalsimport { onCLS, onINP, onLCP, onFCP, onTTFB } from web-vitals onCLS(metric report({ type: web_vital, name: CLS, ...metric })) onINP(metric report({ type: web_vital, name: INP, ...metric })) onLCP(metric report({ type: web_vital, name: LCP, ...metric })) onFCP(metric report({ type: web_vital, name: FCP, ...metric })) onTTFB(metric report({ type: web_vital, name: TTFB, ...metric }))三、行为埋点体系设计1. 埋点类型前端常见埋点分三类。1. 页面访问 PV路由变化时上报router.afterEach((to, from) { report({ type: page_view, path: to.fullPath, title: document.title, referrer: from.fullPath, time: Date.now() }) })2. 点击事件可以手动埋点track(button_click, { buttonName: submit_order, page: route.fullPath })也可以声明式埋点button v-track{ event: submit_order_click, orderId: order.id } 提交订单 /button3. 曝光埋点适合商品曝光广告曝光推荐位曝光列表项曝光使用IntersectionObserverconst observer new IntersectionObserver(entries { entries.forEach(entry { if (entry.isIntersecting) { report({ type: exposure, event: entry.target.dataset.event }) observer.unobserve(entry.target) } }) })Vue 指令app.directive(exposure, { mounted(el, binding) { el.dataset.event binding.value.event observer.observe(el) }, unmounted(el) { observer.unobserve(el) } })使用div v-exposure{ event: product_card_exposure, productId: item.id } ... /div2. 埋点方式选择1. 手动埋点优点精准业务含义清楚参数可控缺点侵入业务代码容易漏埋维护成本高适合关键业务流程注册 登录 下单 支付 搜索 提交表单 领取优惠券2. 可视化埋点优点产品或运营可配置开发参与少缺点实现复杂对 DOM 稳定性依赖高SPA 场景容易受组件结构变化影响适合运营活动页、内容页但不适合核心交易链路。3. 无埋点 / 全埋点自动采集所有点击、路由、输入等行为。优点覆盖全快速回溯用户行为缺点数据噪声大隐私风险高分析成本高可以作为辅助能力不建议完全替代业务埋点。4. 声明式埋点在 Vue 项目里非常实用button v-track{ event: save_profile_click, userId: user.id } 保存 /button优点业务代码相对干净可读性好适合组件化项目推荐组合核心流程手动埋点 普通按钮指令埋点 曝光事件曝光指令 页面访问路由自动埋点 基础点击低频采样自动采集四、上报链路设计1. 统一数据格式建议所有事件统一一层基础结构{ eventId: uuid, eventType: error | behavior | performance, eventName: vue_error, timestamp: 1710000000000, app: { id: mall-web, version: 1.2.3, env: production }, page: { url: location.href, path: location.pathname, title: document.title, referrer: document.referrer }, user: { userId: 123, anonymousId: xxxx, role: buyer }, device: { ua: navigator.userAgent, language: navigator.language, screen: 1920x1080, viewport: 1440x900, network: 4g }, data: {} }核心原则公共字段统一加业务字段放到data错误、性能、行为统一走同一个report上报端不要直接暴露复杂细节给业务代码2. 用户标识设计要支持匿名用户和登录用户。{ anonymousId: 本地生成的 uuid, userId: 登录后设置, sessionId: 一次访问会话 id }匿名 IDfunction getAnonymousId() { let id localStorage.getItem(__track_anonymous_id__) if (!id) { id crypto.randomUUID() localStorage.setItem(__track_anonymous_id__, id) } return id }Session IDfunction getSessionId() { let session sessionStorage.getItem(__track_session_id__) if (!session) { session crypto.randomUUID() sessionStorage.setItem(__track_session_id__, session) } return session }3. 上报方式1.navigator.sendBeacon适合页面卸载时可靠发送navigator.sendBeacon(/api/track, JSON.stringify(data))优点页面关闭时更可靠不阻塞页面跳转缺点只能 POST对响应处理有限数据大小有限制2.fetchkeepalivefetch(/api/track, { method: POST, body: JSON.stringify(data), headers: { Content-Type: application/json }, keepalive: true })3. 图片打点const img new Image() img.src /track.gif?data${encodeURIComponent(JSON.stringify(data))}优点兼容性好不受跨域复杂请求影响缺点数据长度有限不适合复杂数据不适合现代完整监控现在更推荐sendBeacon/fetch。4. 队列与批量上报不要每个事件都立刻请求一次。建议队列const queue [] const MAX_BATCH_SIZE 10 const FLUSH_INTERVAL 5000 function report(event) { queue.push(normalizeEvent(event)) if (queue.length MAX_BATCH_SIZE) { flush() } } function flush() { if (!queue.length) return const list queue.splice(0, queue.length) fetch(/api/track/batch, { method: POST, body: JSON.stringify(list), headers: { Content-Type: application/json }, keepalive: true }).catch(() { // 可以根据需要放回队列但要避免无限增长 }) } setInterval(flush, FLUSH_INTERVAL) window.addEventListener(beforeunload, () { if (queue.length) { navigator.sendBeacon(/api/track/batch, JSON.stringify(queue)) } })5. 采样策略监控系统必须考虑数据量。常见采样function shouldSample(rate 1) { return Math.random() rate }建议JS 异常100% Vue 异常100% 接口 5xx100% 接口 4xx按业务情况 性能数据10% - 30% 点击全埋点1% - 10% 核心业务埋点100% 白屏100%6. 去重与限流同一个错误可能短时间重复上报很多次需要去重。const errorCache new Map() function shouldReportError(errorKey) { const now Date.now() const lastTime errorCache.get(errorKey) if (lastTime now - lastTime 10000) { return false } errorCache.set(errorKey, now) return true }errorKey 可以由message filename lineno colno message stack 前几行 接口 url status code7. 脱敏不能上报密码tokencookie身份证手机号银行卡精确地址用户输入的大段文本接口参数也要过滤const sensitiveKeys [password, token, authorization, cookie] function sanitize(obj) { if (!obj || typeof obj ! object) return obj const result Array.isArray(obj) ? [] : {} Object.keys(obj).forEach(key { if (sensitiveKeys.includes(key.toLowerCase())) { result[key] [FILTERED] } else { result[key] sanitize(obj[key]) } }) return result }五、Vue 项目落地方案下面给一个 Vue 3 项目的基础 SDK 设计。目录可以这样放src/ monitor/ index.js reporter.js error.js performance.js behavior.js directives.js utils.js1. reporter.js// src/monitor/reporter.js const queue [] let options {} const MAX_BATCH_SIZE 10 const FLUSH_INTERVAL 5000 export function initReporter(config) { options config setInterval(flush, FLUSH_INTERVAL) window.addEventListener(beforeunload, () { flush(true) }) } export function report(event) { const data normalizeEvent(event) if (!shouldSample(data)) return queue.push(data) if (queue.length MAX_BATCH_SIZE) { flush() } } function normalizeEvent(event) { return { eventId: crypto.randomUUID(), eventType: event.eventType || behavior, eventName: event.eventName || event.type, timestamp: Date.now(), app: { id: options.appId, version: options.version, env: options.env }, page: { url: location.href, path: location.pathname, title: document.title, referrer: document.referrer }, user: { userId: options.getUserId?.(), anonymousId: getAnonymousId(), sessionId: getSessionId() }, device: { ua: navigator.userAgent, language: navigator.language, screen: ${screen.width}x${screen.height}, viewport: ${window.innerWidth}x${window.innerHeight}, network: navigator.connection?.effectiveType }, data: event.data || {} } } function flush(useBeacon false) { if (!queue.length || !options.reportUrl) return const list queue.splice(0, queue.length) const body JSON.stringify(list) if (useBeacon navigator.sendBeacon) { navigator.sendBeacon(options.reportUrl, body) return } fetch(options.reportUrl, { method: POST, body, headers: { Content-Type: application/json }, keepalive: true }).catch(() { // 生产环境可做本地缓存重试但要限制容量 }) } function shouldSample(event) { const rate options.sampleRate?.[event.eventName] if (typeof rate number) { return Math.random() rate } return true } function getAnonymousId() { const key __monitor_anonymous_id__ let id localStorage.getItem(key) if (!id) { id crypto.randomUUID() localStorage.setItem(key, id) } return id } function getSessionId() { const key __monitor_session_id__ let id sessionStorage.getItem(key) if (!id) { id crypto.randomUUID() sessionStorage.setItem(key, id) } return id }2. error.js// src/monitor/error.js import { report } from ./reporter const errorCache new Map() export function initErrorMonitor(app) { window.addEventListener(error, handleError, true) window.addEventListener(unhandledrejection, handleUnhandledRejection) app.config.errorHandler (err, instance, info) { reportError({ type: vue_error, message: err.message, stack: err.stack, component: instance?.type?.name, info }) } } function handleError(event) { const target event.target if (target (target.src || target.href)) { reportError({ type: resource_error, tagName: target.tagName, url: target.src || target.href }) return } reportError({ type: js_error, message: event.message, filename: event.filename, lineno: event.lineno, colno: event.colno, stack: event.error?.stack }) } function handleUnhandledRejection(event) { const reason event.reason reportError({ type: promise_error, message: reason?.message || String(reason), stack: reason?.stack }) } function reportError(data) { const key ${data.type}_${data.message}_${data.filename}_${data.lineno}_${data.colno} if (!shouldReport(key)) return report({ eventType: error, eventName: data.type, data }) } function shouldReport(key) { const now Date.now() const last errorCache.get(key) if (last now - last 10000) { return false } errorCache.set(key, now) return true }3. performance.js// src/monitor/performance.js import { report } from ./reporter export function initPerformanceMonitor(router) { collectNavigationPerformance() collectRoutePerformance(router) collectWebVitals() } function collectNavigationPerformance() { window.addEventListener(load, () { setTimeout(() { const nav performance.getEntriesByType(navigation)[0] if (!nav) return report({ eventType: performance, eventName: navigation_timing, data: { dns: nav.domainLookupEnd - nav.domainLookupStart, tcp: nav.connectEnd - nav.connectStart, ssl: nav.connectEnd - nav.secureConnectionStart, ttfb: nav.responseStart - nav.requestStart, domReady: nav.domContentLoadedEventEnd - nav.startTime, load: nav.loadEventEnd - nav.startTime } }) }) }) } function collectRoutePerformance(router) { let startTime 0 router.beforeEach((to, from, next) { startTime performance.now() next() }) router.afterEach(to { const duration performance.now() - startTime report({ eventType: performance, eventName: route_change, data: { path: to.fullPath, duration } }) }) } function collectWebVitals() { // 可接入 web-vitals }4. behavior.js// src/monitor/behavior.js import { report } from ./reporter export function initBehaviorMonitor(router) { router.afterEach((to, from) { report({ eventType: behavior, eventName: page_view, data: { path: to.fullPath, from: from.fullPath, title: document.title } }) }) } export function track(eventName, data {}) { report({ eventType: behavior, eventName, data }) }业务中使用import { track } from /monitor/behavior track(submit_order_click, { orderId: order.id, amount: order.amount })5. directives.js// src/monitor/directives.js import { track } from ./behavior export function registerTrackDirectives(app) { app.directive(track, { mounted(el, binding) { el.addEventListener(click, () { const value binding.value || {} track(value.event, { ...value, event: undefined }) }) } }) const observer new IntersectionObserver(entries { entries.forEach(entry { if (!entry.isIntersecting) return const value entry.target.__exposureValue__ if (value?.event) { track(value.event, value) } observer.unobserve(entry.target) }) }) app.directive(exposure, { mounted(el, binding) { el.__exposureValue__ binding.value observer.observe(el) }, updated(el, binding) { el.__exposureValue__ binding.value }, unmounted(el) { observer.unobserve(el) delete el.__exposureValue__ } }) }Vue 组件中template button v-track{ event: save_profile_click, userId: user.id } 保存 /button ProductCard v-foritem in list :keyitem.id v-exposure{ event: product_exposure, productId: item.id } :productitem / /template6. index.js// src/monitor/index.js import { initReporter } from ./reporter import { initErrorMonitor } from ./error import { initPerformanceMonitor } from ./performance import { initBehaviorMonitor } from ./behavior import { registerTrackDirectives } from ./directives export function setupMonitor(app, router, options) { initReporter(options) initErrorMonitor(app) initPerformanceMonitor(router) initBehaviorMonitor(router) registerTrackDirectives(app) }7. main.js 接入import { createApp } from vue import App from ./App.vue import router from ./router import { setupMonitor } from ./monitor const app createApp(App) setupMonitor(app, router, { appId: mall-web, version: import.meta.env.VITE_APP_VERSION, env: import.meta.env.MODE, reportUrl: /api/monitor/batch, getUserId: () { return localStorage.getItem(userId) }, sampleRate: { page_view: 1, route_change: 0.3, navigation_timing: 0.2 } }) app.use(router) app.mount(#app)六、Axios 接口监控接入如果项目统一使用 axios建议在请求封装层做。import axios from axios import { report } from /monitor/reporter const service axios.create({ timeout: 10000 }) service.interceptors.request.use(config { config.metadata { startTime: Date.now() } return config }) service.interceptors.response.use( response { const duration Date.now() - response.config.metadata.startTime if (duration 3000) { report({ eventType: performance, eventName: slow_api, data: { url: response.config.url, method: response.config.method, duration } }) } const data response.data if (data data.code ! 0) { report({ eventType: error, eventName: business_api_error, data: { url: response.config.url, method: response.config.method, code: data.code, message: data.message, duration } }) } return response }, error { const config error.config || {} const duration config.metadata ? Date.now() - config.metadata.startTime : 0 report({ eventType: error, eventName: http_error, data: { url: config.url, method: config.method, status: error.response?.status, message: error.message, duration } }) return Promise.reject(error) } ) export default service注意不要直接上报完整请求参数和响应体容易泄露敏感数据。七、Source Map 与错误还原生产环境 JS 通常被压缩混淆线上错误可能是TypeError: Cannot read properties of undefined at a.b.c (app.8f3a1.js:1:32881)这对排查没什么价值所以要接入 Source Map。流程前端构建生成 source map ↓ 构建时上传 source map 到监控平台 ↓ 线上报错只上报 js 文件、行列号、stack ↓ 服务端用 source map 还原源码位置 ↓ 展示原始文件、行列、函数名Vite 配置// vite.config.js export default { build: { sourcemap: true } }但不要把.map文件公开给用户访问建议构建生成 sourcemapCI 上传到监控服务上传后删除产物中的.mapfind dist -name *.map -delete生产上报中需要带版本号{ app: { id: mall-web, version: 1.2.3 } }服务端根据appId version fileName找对应 source map。八、服务端接收与数据处理前端不是整个体系的终点。服务端至少要做接收数据 校验字段 限流 脱敏 聚合 存储 告警 可视化数据可以分流异常数据 → Elasticsearch / ClickHouse 行为数据 → Kafka → ClickHouse / Hive 性能数据 → ClickHouse / Prometheus中小项目可以简单一点Node 接收接口 ↓ 写入 MySQL / PostgreSQL / MongoDB ↓ 后台页面查询 ↓ 严重错误接入企业微信 / 飞书告警示例 Node 接口app.post(/api/monitor/batch, express.json({ limit: 200kb }), async (req, res) { const events Array.isArray(req.body) ? req.body : [] for (const event of events) { // 校验、脱敏、入库 } res.sendStatus(204) })九、告警设计不是所有错误都要告警否则没人看。建议告警规则白屏立即告警 JS 错误同版本同错误 5 分钟内超过 N 次 接口 5xx1 分钟内超过 N 次 支付/下单/登录接口异常立即或低阈值告警 LCP/INP 大面积劣化按周期告警 资源加载失败核心 JS/CSS 失败立即告警告警内容要包含应用名 环境 版本 错误类型 错误信息 影响用户数 发生次数 首次发生时间 最近发生时间 页面 URL 浏览器 / 系统 Source Map 还原位置 最近发布版本十、Vue 项目中的推荐实践1. 推荐架构monitor SDK 独立封装 ↓ main.js 统一初始化 ↓ router 自动采集 PV 和路由性能 ↓ Vue errorHandler 捕获组件异常 ↓ axios 拦截器捕获接口异常 ↓ v-track 处理点击埋点 ↓ v-exposure 处理曝光埋点 ↓ track() 处理核心业务手动埋点2. Vue 项目埋点规范建议制定事件命名规范页面访问page_view 按钮点击xxx_click 曝光xxx_exposure 提交xxx_submit 成功xxx_success 失败xxx_fail 接口异常api_error示例login_page_view login_submit_click login_success login_fail product_card_exposure order_submit_click payment_success payment_fail事件参数规范track(order_submit_click, { orderId, amount, skuCount, couponUsed, sourcePage })不要出现这种无意义事件名track(click) track(button_click) track(submit)事件名要能直接看出业务含义。3. Vue Router 注意点SPA 应用中页面不会刷新所以必须监听路由router.afterEach((to, from) { track(page_view, { path: to.fullPath, from: from.fullPath }) })如果用了keep-alive页面停留时长要特别处理let enterTime Date.now() router.beforeEach((to, from, next) { if (from.fullPath) { track(page_stay, { path: from.fullPath, duration: Date.now() - enterTime }) } enterTime Date.now() next() })4. 组件级埋点对于复用组件建议通过 props 传入埋点上下文。ProductCard :productitem :track-context{ module: recommend, position: index 1 } /组件内部track(product_card_click, { productId: props.product.id, module: props.trackContext.module, position: props.trackContext.position })这样比在每个页面手写重复埋点更可维护。5. 权限与隐私要特别注意不采集密码输入框内容不采集完整表单内容不采集 token、cookie不上传完整请求头不上传完整用户输入用户 ID 尽量使用内部 ID不上传手机号如果面向海外用户要考虑 GDPR / Cookie Consent十一、常见坑1. 只接了window.onerror没接 Vue errorHandlerVue 组件异常上下文会丢很多排查体验很差。2. 接口异常上报了完整 response容易泄露隐私数据也会导致上报体过大。3. 没有 source map生产错误无法定位源码监控系统价值大幅下降。4. 每个事件一个请求高频点击或曝光会造成请求风暴必须批量上报。5. 所有点击都 100% 上报数据量大、噪声大、成本高。核心事件 100%普通行为采样。6. 事件名没有规范后期数据分析会非常痛苦。埋点体系最重要的是“事件语义稳定”。7. 没有版本号发布后无法判断问题属于哪个版本也无法匹配 source map。十二、总结方案对于一个 Vue 前端项目我建议这样设计1. 封装 monitor SDK 2. main.js 初始化 3. 全局捕获 JS / Promise / Vue / Resource 异常 4. Axios 拦截器采集接口异常和慢请求 5. Router 采集 PV、停留时长、路由性能 6. Performance API web-vitals 采集性能指标 7. v-track 做点击埋点 8. v-exposure 做曝光埋点 9. track() 做核心业务手动埋点 10. 队列批量上报beforeunload 用 sendBeacon 11. 支持采样、去重、限流、脱敏 12. 构建阶段上传 source map 13. 服务端聚合、告警、可视化核心原则是异常监控要能定位问题 行为埋点要有业务语义 上报链路要可靠且低成本 数据字段要统一 隐私和脱敏必须前置考虑 Vue 项目要充分利用 router、errorHandler、directive、axios 拦截器