真实用户监控(RUM):洞察用户真实体验
真实用户监控(RUM)洞察用户真实体验前言作为前端开发者你是否想知道用户在使用你的应用时的真实体验他们遇到了什么问题在什么设备上使用网络状况如何真实用户监控RUM就是为了解决这些问题而生的。它可以帮助你收集真实用户的使用数据了解他们的真实体验从而做出更明智的优化决策。今天我们就来深入探讨如何建立一套完善的前端RUM体系。什么是RUMRUMReal User Monitoring是一种监控真实用户在使用应用过程中的体验数据的方法。它与传统的合成监控不同后者是在实验室环境中模拟用户行为而RUM则是收集真实用户的真实数据。RUM与合成监控的对比特性RUM合成监控数据源真实用户模拟用户环境真实环境实验室环境覆盖范围全面有限数据量大量少量真实性高中等RUM的核心价值了解真实用户体验获取真实用户的性能数据发现隐藏问题找到实验室中无法发现的问题优化决策依据基于真实数据做出优化决策持续改进持续监控和改进用户体验RUM核心指标1. 性能指标指标说明关注重点LCP最大内容绘制时间首屏加载速度FID首次输入延迟交互响应速度CLS累积布局偏移视觉稳定性TTFB首字节时间服务器响应速度TBT总阻塞时间主线程阻塞情况2. 用户设备信息信息说明分析价值浏览器用户使用的浏览器兼容性问题分析设备类型桌面/移动/平板设备适配优化操作系统Windows/macOS/iOS/Android平台特定问题屏幕分辨率显示分辨率响应式设计优化3. 网络状况指标说明优化方向网络类型4G/3G/2G/Wi-Fi针对弱网优化延迟网络延迟CDN优化带宽可用带宽资源压缩4. 用户行为行为说明分析价值页面浏览访问的页面流量分析点击事件用户点击的元素交互分析表单提交表单填写情况转化率分析停留时间页面停留时长内容质量实战搭建RUM系统第一步RUM数据收集器// RUM数据收集器 class RUMCollector { constructor(options {}) { this.options { endpoint: /api/rum, sampleRate: 0.1, sessionTimeout: 30 * 60 * 1000, ...options }; this.sessionId this.getSessionId(); this.userId this.getUserId(); this.pageStartTime Date.now(); this.data { session: { id: this.sessionId, userId: this.userId, startTime: this.pageStartTime }, performance: {}, errors: [], userActions: [], pageView: null }; this.init(); } getSessionId() { let sessionId localStorage.getItem(rum_session_id); const sessionTime localStorage.getItem(rum_session_time); if (!sessionId || Date.now() - sessionTime this.options.sessionTimeout) { sessionId ${Date.now()}-${Math.random().toString(36).substr(2, 9)}; localStorage.setItem(rum_session_id, sessionId); localStorage.setItem(rum_session_time, Date.now().toString()); } return sessionId; } getUserId() { return localStorage.getItem(rum_user_id) || anonymous-${Math.random().toString(36).substr(2, 9)}; } init() { this.trackPageView(); this.collectPerformanceMetrics(); this.collectErrors(); this.collectUserActions(); this.collectEnvironmentInfo(); // 页面卸载时上报 window.addEventListener(beforeunload, () this.sendData()); // 定时上报 setInterval(() this.sendData(), 30000); } trackPageView() { this.data.pageView { url: window.location.href, title: document.title, referrer: document.referrer, timestamp: Date.now() }; } collectPerformanceMetrics() { // LCP const lcpObserver new PerformanceObserver((entryList) { const entries entryList.getEntries(); const lcpEntry entries[entries.length - 1]; if (lcpEntry) { this.data.performance.lcp { value: lcpEntry.startTime lcpEntry.duration, element: lcpEntry.element?.tagName, url: lcpEntry.url, startTime: lcpEntry.startTime }; } }); lcpObserver.observe({ type: largest-contentful-paint, buffered: true }); // FID let fid 0; const fidObserver new PerformanceObserver((entryList) { entryList.getEntries().forEach(entry { const inputDelay entry.processingStart - entry.startTime; if (inputDelay fid) { fid inputDelay; } }); }); fidObserver.observe({ type: first-input, buffered: true }); // CLS let cls 0; const clsObserver new PerformanceObserver((entryList) { entryList.getEntries().forEach(entry { if (!entry.hadRecentInput) { cls entry.value; } }); }); clsObserver.observe({ type: layout-shift, buffered: true }); // 页面隐藏时记录指标 document.addEventListener(visibilitychange, () { if (document.hidden) { this.data.performance.fid { value: fid }; this.data.performance.cls { value: cls }; this.data.performance.timeOnPage Date.now() - this.pageStartTime; } }); // 收集导航时序数据 const navigationTiming performance.getEntriesByType(navigation)[0]; if (navigationTiming) { this.data.performance.navigation { ttfb: navigationTiming.responseStart - navigationTiming.navigationStart, domContentLoaded: navigationTiming.domContentLoadedEventEnd - navigationTiming.navigationStart, load: navigationTiming.loadEventEnd - navigationTiming.navigationStart }; } } collectErrors() { window.addEventListener(error, (event) { this.data.errors.push({ type: javascript_error, message: event.message, filename: event.filename, line: event.lineno, column: event.colno, stack: event.error?.stack || , timestamp: Date.now() }); }); window.addEventListener(unhandledrejection, (event) { this.data.errors.push({ type: promise_rejection, message: event.reason?.message || String(event.reason), stack: event.reason?.stack || , timestamp: Date.now() }); }); } collectUserActions() { let actionCount 0; const handleClick (event) { actionCount; const target event.target; this.data.userActions.push({ type: click, element: { tagName: target.tagName, className: target.className, id: target.id, text: target.textContent?.trim().slice(0, 50) }, timestamp: Date.now(), x: event.clientX, y: event.clientY }); }; const handleInput (event) { const target event.target; if (target.tagName INPUT || target.tagName TEXTAREA) { this.data.userActions.push({ type: input, element: { tagName: target.tagName, id: target.id, name: target.name, type: target.type }, timestamp: Date.now() }); } }; document.addEventListener(click, handleClick); document.addEventListener(input, handleInput); } collectEnvironmentInfo() { this.data.environment { browser: { name: this.getBrowserName(), version: this.getBrowserVersion() }, device: { type: this.getDeviceType(), screenWidth: window.innerWidth, screenHeight: window.innerHeight }, os: this.getOS(), network: { type: navigator.connection?.effectiveType || unknown, downlink: navigator.connection?.downlink || unknown }, userAgent: navigator.userAgent, language: navigator.language }; } getBrowserName() { const userAgent navigator.userAgent; if (userAgent.includes(Chrome) !userAgent.includes(Edg)) return Chrome; if (userAgent.includes(Firefox)) return Firefox; if (userAgent.includes(Safari) !userAgent.includes(Chrome)) return Safari; if (userAgent.includes(Edg)) return Edge; if (userAgent.includes(Opera) || userAgent.includes(OPR)) return Opera; return Unknown; } getBrowserVersion() { const matches navigator.userAgent.match(/(Chrome|Firefox|Safari|Edg|Opera)\/(\d)/); return matches ? matches[2] : Unknown; } getDeviceType() { if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) { return mobile; } return desktop; } getOS() { const userAgent navigator.userAgent; if (userAgent.includes(Windows)) return Windows; if (userAgent.includes(Mac OS)) return macOS; if (userAgent.includes(Linux)) return Linux; if (userAgent.includes(Android)) return Android; if (userAgent.includes(iPhone) || userAgent.includes(iPad)) return iOS; return Unknown; } async sendData() { if (Math.random() this.options.sampleRate) return; try { const payload { ...this.data, timestamp: Date.now() }; await fetch(this.options.endpoint, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload) }); // 重置用户行为数据保留页面视图和性能数据 this.data.userActions []; this.data.errors []; } catch (error) { console.error(RUM data send failed:, error); } } } // 初始化RUM收集器 const rum new RUMCollector({ endpoint: https://api.example.com/rum, sampleRate: 0.1 });第二步RUM数据处理服务// RUM数据处理服务 const express require(express); const app express(); const { Pool } require(pg); app.use(express.json()); const pool new Pool({ connectionString: process.env.DATABASE_URL }); // 接收RUM数据 app.post(/api/rum, async (req, res) { const { session, performance, errors, userActions, pageView, environment } req.body; try { // 存储会话数据 await pool.query( INSERT INTO rum_sessions (session_id, user_id, start_time, url, referrer, browser, os, device_type, network_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9), [session.id, session.userId, new Date(session.startTime), pageView.url, pageView.referrer, environment.browser.name, environment.os, environment.device.type, environment.network.type] ); // 存储性能数据 if (performance.lcp) { await pool.query( INSERT INTO rum_performance (session_id, lcp, fid, cls, ttfb, dom_content_loaded, load_time, time_on_page) VALUES ($1, $2, $3, $4, $5, $6, $7, $8), [session.id, performance.lcp?.value, performance.fid?.value, performance.cls?.value, performance.navigation?.ttfb, performance.navigation?.domContentLoaded, performance.navigation?.load, performance.timeOnPage] ); } // 存储错误数据 for (const error of errors) { await pool.query( INSERT INTO rum_errors (session_id, type, message, filename, line, column, stack, timestamp) VALUES ($1, $2, $3, $4, $5, $6, $7, $8), [session.id, error.type, error.message, error.filename, error.line, error.column, error.stack, new Date(error.timestamp)] ); } // 存储用户行为数据 for (const action of userActions) { await pool.query( INSERT INTO rum_user_actions (session_id, type, element_info, timestamp) VALUES ($1, $2, $3, $4), [session.id, action.type, JSON.stringify(action.element), new Date(action.timestamp)] ); } res.json({ message: RUM data received }); } catch (error) { console.error(Error storing RUM data:, error); res.status(500).json({ message: Internal server error }); } }); // 获取RUM统计数据 app.get(/api/rum/stats, async (req, res) { const { days 7 } req.query; try { const result await pool.query( SELECT COUNT(DISTINCT session_id) as total_sessions, COUNT(DISTINCT user_id) as unique_users, AVG(lcp) as avg_lcp, AVG(fid) as avg_fid, AVG(cls) as avg_cls, AVG(time_on_page) as avg_time_on_page, COUNT(*) as total_errors FROM rum_sessions LEFT JOIN rum_performance ON rum_sessions.session_id rum_performance.session_id LEFT JOIN rum_errors ON rum_sessions.session_id rum_errors.session_id WHERE rum_sessions.start_time NOW() - INTERVAL ${days} days ); res.json(result.rows[0]); } catch (error) { console.error(Error fetching RUM stats:, error); res.status(500).json({ message: Internal server error }); } }); app.listen(3000, () { console.log(RUM service running on port 3000); });第三步RUM可视化仪表盘// RUM仪表盘组件 class RUMDashboard { constructor() { this.data null; } async init() { await this.loadData(); this.render(); } async loadData() { const response await fetch(/api/rum/stats?days7); this.data await response.json(); } render() { const dashboard div classrum-dashboard div classdashboard-header h1真实用户监控/h1 div classdate-range button classdate-btn active>// 根据用户类型调整采样率 function getSampleRate() { const userType localStorage.getItem(user_type); if (userType premium) { return 0.5; // 付费用户更高采样率 } const hour new Date().getHours(); if (hour 9 hour 18) { return 0.05; // 高峰期降低采样率 } return 0.2; }2. 数据压缩// 压缩RUM数据 function compressRUMData(data) { // 移除不必要的字段 const compressed { s: data.session.id, u: data.session.userId, p: { l: data.performance.lcp?.value, f: data.performance.fid?.value, c: data.performance.cls?.value }, e: data.errors.map(e ({ t: e.type, m: e.message })), a: data.userActions.length, d: data.environment.device.type }; return compressed; }3. 用户隐私保护// 数据脱敏 function sanitizeRUMData(data) { const sanitized { ...data }; // 移除敏感信息 if (sanitized.pageView?.url) { sanitized.pageView.url sanitized.pageView.url .replace(/\?.*$/, ) // 移除查询参数 .replace(/\/users\/\d/, /users/[id]); } // 匿名化用户ID if (sanitized.session?.userId) { sanitized.session.userId anonymous; } return sanitized; }常见问题Q1: RUM会影响用户体验吗A: 通过采样率控制和异步上报影响可以忽略不计。Q2: 如何处理大量RUM数据A: 使用时序数据库存储配合数据聚合和采样策略。Q3: 是否需要收集所有用户数据A: 不一定可以根据业务需求选择性收集。Q4: 如何保护用户隐私A: 对敏感数据进行脱敏处理遵循隐私法规。Q5: RUM数据可以用于哪些分析A: 性能分析、用户行为分析、设备分布分析、错误分析等。总结RUM是前端监控的重要组成部分通过收集真实用户数据可以了解真实用户体验发现隐藏问题做出数据驱动的优化决策持续改进用户体验结合Core Web Vitals、用户行为和设备信息你可以打造一个全面的RUM系统。延伸阅读Google Analytics 4New Relic RUMDatadog RUM