├── StatisticSDK.js ├── src ├── utils │ ├── onload.js │ ├── getLastEvent.js │ ├── throttle.js │ ├── getSelector.js │ └── tracker.js ├── index.js ├── lib │ ├── longTask.js │ ├── pv.js │ ├── xhr.js │ ├── blankScreen.js │ ├── jsError.js │ └── timing.js └── index.html ├── .gitignore ├── package.json ├── webpack.config.js └── 前端监控文档.md /StatisticSDK.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/onload.js: -------------------------------------------------------------------------------- 1 | export default function(callback) { 2 | if (document.readyState === 'complete') { 3 | callback() 4 | } else { 5 | window.addEventListener('load', callback) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import {injectJsError} from './lib/jsError' 2 | import {injectXHR} from './lib/xhr' 3 | import {blankScreen} from './lib/blankScreen' 4 | import {timing} from './lib/timing' 5 | import {longTask} from './lib/longTask' 6 | 7 | injectJsError() 8 | injectXHR() 9 | blankScreen() 10 | timing() 11 | longTask() 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw* 22 | 23 | build/env.js 24 | -------------------------------------------------------------------------------- /src/utils/getLastEvent.js: -------------------------------------------------------------------------------- 1 | let lastEvent 2 | ['click', 'touchstart', 'mousedown', 'keydown', 'mouseover'].forEach(eventType => { 3 | document.addEventListener(eventType, (event) => { 4 | lastEvent = event 5 | }, { 6 | capture: true, // 捕获阶段 7 | passive: true // 被动模式 默认不阻止默认事件 8 | }) 9 | }) 10 | 11 | export default function () { 12 | return lastEvent 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "monitoringSDK", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server", 8 | "build": "webpack" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "html-webpack-plugin": "^5.5.0", 15 | "user-agent": "^1.0.4", 16 | "webpack": "^5.72.1", 17 | "webpack-cli": "^4.9.2", 18 | "webpack-dev-server": "^4.9.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/throttle.js: -------------------------------------------------------------------------------- 1 | export function throttle(fn, delay) { 2 | let timer = null 3 | let startTime = Date.now() 4 | return function () { 5 | let curTime = Date.now() // 当前时间 6 | let remaining = delay - (curTime - startTime) // 从上一次到现在,还剩下多少多余时间 7 | let context = this 8 | let args = arguments 9 | clearTimeout(timer) 10 | if (remaining <= 0) { 11 | fn.apply(context, args) 12 | startTime = Date.now() 13 | } else { 14 | timer = setTimeout(fn, remaining) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/getSelector.js: -------------------------------------------------------------------------------- 1 | function getSelectors(path) { 2 | return path.reverse().filter(element => { 3 | return element !== document && element !== window // 去除 document 和 window 4 | }).map(element => { 5 | let selector = "" 6 | if (element.id) { 7 | return `${element.nodeName.toLowerCase()}#${element.id}` 8 | } else if (element.className && typeof element.className === 'string') { 9 | return `${element.nodeName.toLowerCase()}.${element.className.split(' ').join('.')}` 10 | } else { 11 | selector = element.nodeName.toLowerCase() 12 | } 13 | return selector 14 | }).join(' ') 15 | } 16 | 17 | export default function (pathsOrTarget) { 18 | if (Array.isArray(pathsOrTarget)) { 19 | return getSelectors(pathsOrTarget) 20 | } else { 21 | let path = [] 22 | while (pathsOrTarget) { 23 | path.push(pathsOrTarget) 24 | pathsOrTarget = pathsOrTarget.parentNode 25 | } 26 | return getSelectors(path) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/longTask.js: -------------------------------------------------------------------------------- 1 | import tracker from '../utils/tracker' 2 | import getLastEvent from '../utils/getLastEvent' 3 | import getSelector from '../utils/getSelector' 4 | 5 | export function longTask() { 6 | if (PerformanceObserver) { 7 | new PerformanceObserver((entryList) => { 8 | let perfEntries = entryList.getEntries() 9 | perfEntries.forEach((entry) => { 10 | if (entry.duration > 100) { 11 | let lastEvent = getLastEvent() 12 | // console.log('lastEvent', lastEvent) 13 | let selector = lastEvent 14 | ? getSelector(lastEvent.path || lastEvent.target) 15 | : '' 16 | requestIdleCallback(() => { 17 | tracker.send({ 18 | kind: 'experience', 19 | type: 'longTask', 20 | eventType: lastEvent ? lastEvent.type : '', 21 | duration: entry.duration, 22 | startTime: entry.startTime, 23 | selector, 24 | }) 25 | }) 26 | } 27 | }) 28 | }).observe({ entryTypes: ['longtask'] }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | let host = 'cn-shanghai.log.aliyuncs.com' 4 | let project = 'czwmonitor' 5 | 6 | module.exports = { 7 | entry: './src/index.js', 8 | context: process.cwd(), 9 | mode: 'development', 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename: 'monitor.js' 13 | }, 14 | devServer: { 15 | static: path.join(__dirname, 'dist'), 16 | onAfterSetupMiddleware: function(devServer) { 17 | devServer.app.get('/success', function(req, res) { 18 | res.json({ id: 1 }) 19 | }) 20 | devServer.app.post('/fail', function(req, res) { 21 | res.sendStatus(500) 22 | }) 23 | }, 24 | proxy: { 25 | '/api': { 26 | target: `http://${project}.${host}`, 27 | pathRewrite: { '^/api': '' }, 28 | changeOrigin: true 29 | } 30 | } 31 | }, 32 | plugins: [ 33 | new HtmlWebpackPlugin({ 34 | template: './src/index.html', 35 | inject: 'head', 36 | scriptLoading: 'blocking', 37 | }) 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /src/lib/pv.js: -------------------------------------------------------------------------------- 1 | import tracker from '../utils/tracker' 2 | 3 | /** 4 | * @description 上报 pv 5 | * @description 切换页面时,路由切换时进行上报 6 | * @description hash 路由,在 hashchange 事件进行上报 7 | * @description history 路由,在 popstate 事件进行上报,pushState 和 replaceState 不会触发事件,重写这两个方法进行上报 8 | */ 9 | export function injectPv() { 10 | function bindEvent(type, fn) { 11 | return function (...args) { 12 | const value = fn.apply(window.history, args) 13 | const e = new Event(type) 14 | window.dispatchEvent(e) 15 | return value 16 | } 17 | } 18 | 19 | // 重写 pushState 和 replaceState 20 | window.history.pushState = bindEvent('pushState', window.history.pushState) 21 | window.history.replaceState = bindEvent('replaceState', window.history.replaceState) 22 | 23 | // 对包括重写后的事件进行监听 24 | const PV_EVENTS = ['hashchange', 'popstate', 'pushState', 'replaceState'] 25 | PV_EVENTS.forEach(event => { 26 | window.addEventListener(event, () => { 27 | const path = event === 'hashchange' ? location.hash.slice(1) : location.pathname 28 | tracker.send({ 29 | kind: 'business', 30 | type: 'pv', 31 | path 32 | }) 33 | }) 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/xhr.js: -------------------------------------------------------------------------------- 1 | import tracker from '../utils/tracker' 2 | 3 | export function injectXHR() { 4 | let XMLHttpRequest = window.XMLHttpRequest 5 | let oldOpen = XMLHttpRequest.prototype.open 6 | XMLHttpRequest.prototype.open = function (method, url, async) { 7 | if (!url.match(/logstores/) && !url.match(/sockjs/)) { 8 | this.logData = { method, url, async } 9 | } 10 | return oldOpen.apply(this, arguments) 11 | } 12 | let oldSend = XMLHttpRequest.prototype.send 13 | let startTime 14 | XMLHttpRequest.prototype.send = function (body) { 15 | if (this.logData) { 16 | startTime = Date.now() // 请求开始时间 17 | let handler = (type) => (event) => { 18 | let duration = Date.now() - startTime // 请求持续时间 19 | let status = this.statusText // 2xx-4xx-5xx 20 | let statusText = this.statusText // 请求状态 OK Server Error 21 | let log = { 22 | kind: 'stability', // 监控指标的大类 23 | type: 'xhr', // 小类型 这是一个接口请求错误 24 | eventType: type, // load error abort 25 | pathname: this.logData.url, // 请求路径 26 | status: status + '-' + statusText, // 状态码 + 状态码描述 27 | response: this.response ? JSON.stringify(this.response) : '', // 响应内容 28 | params: body || '' // 请求参数 29 | } 30 | // 上报 31 | tracker.send(log) 32 | } 33 | this.addEventListener('load', handler('load'), false) 34 | this.addEventListener('error', handler('error'), false) 35 | this.addEventListener('abort', handler('abort'), false) 36 | } 37 | return oldSend.apply(this, arguments) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/lib/blankScreen.js: -------------------------------------------------------------------------------- 1 | import tracker from '../utils/tracker' 2 | import onload from '../utils/onload' 3 | 4 | export function blankScreen() { 5 | let wrapperElements = ['html', 'body', '#container', '.content'] 6 | let emptyPoints = 0 7 | function getSelector(element) { 8 | if (element.id) { 9 | return `#${element.id}` 10 | } else if (element.className && typeof element.className === 'string') { 11 | return `.${element.className.split(' ').filter(item => !!item).join('.')}` 12 | } else { 13 | return element.nodeName.toLowerCase() 14 | } 15 | } 16 | function isWrapper(element) { 17 | let selector = getSelector(element) 18 | if (wrapperElements.indexOf(selector) !== -1) { 19 | emptyPoints++ 20 | } 21 | } 22 | onload(function() { 23 | for (let i = 1; i <= 59; i++) { 24 | let xElements = document.elementsFromPoint( // 屏幕中心十字线横线上 59 个点 25 | window.innerWidth * i / 60, 26 | window.innerHeight / 2 27 | ) 28 | let yElements = document.elementsFromPoint( // 屏幕中心十字线竖线上 59 个点 29 | window.innerWidth / 2, 30 | window.innerHeight * i / 60 31 | ) 32 | isWrapper(xElements[0]) 33 | isWrapper(yElements[0]) 34 | } 35 | if (emptyPoints > 116) { 36 | let centerElement = document.elementsFromPoint(window.innerWidth / 2, window.innerHeight / 2) 37 | let log = { 38 | kind: 'stability', 39 | type: 'blank', 40 | emptyPoints, 41 | screen: window.screen.width + 'x' + window.screen.height, 42 | viewPoint: window.innerWidth + 'x' + window.innerHeight, 43 | selector: getSelector(centerElement[0]) 44 | } 45 | tracker.send(log) 46 | } 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | monitorSDK 8 | 9 | 10 |
11 |
12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 | 30 | 31 | 32 | 55 | 56 | 64 | 65 | 74 | 75 | 83 | 84 | -------------------------------------------------------------------------------- /src/utils/tracker.js: -------------------------------------------------------------------------------- 1 | import { throttle } from './throttle' 2 | 3 | let host = 'cn-shanghai.log.aliyuncs.com' 4 | let project = 'czwmonitor' 5 | let logStore = 'czwmonitor-store' 6 | let userAgent = require('user-agent') 7 | let qs = require('qs') 8 | 9 | function getExtraData() { 10 | return { 11 | title: document.title, 12 | url: location.href, 13 | timestamp: Date.now(), 14 | userAgent: userAgent.parse(navigator.userAgent).name, 15 | // 用户ID 16 | } 17 | } 18 | 19 | const queue = [] 20 | function doLowProMissions (IdleDeadline) { 21 | while (IdleDeadline.timeRemaining() && queue.length) { 22 | let url = queue.shift() 23 | console.log('url', url) 24 | let img = new Image() 25 | img.setAttribute('crossOrigin', 'anonymous') 26 | img.src = url 27 | } 28 | if (queue.length) { 29 | requestIdleCallback(doLowProMissions) 30 | } 31 | } 32 | 33 | class SendTracker { 34 | constructor() { 35 | this.url = `/api/logstores/${logStore}/track` // 上报路径 36 | this.xhr = new XMLHttpRequest() 37 | this.queue = [] 38 | } 39 | idle(IdleDeadline) { 40 | while (IdleDeadline.timeRemaining() > 0 && this.queue.length > 0) { 41 | let url = this.queue.shift() 42 | let img = new Image() 43 | img.setAttribute('crossOrigin', 'anonymous') 44 | img.src = url 45 | } 46 | if (this.queue.length > 0) { 47 | requestIdleCallback(this.idle.bind(this, requestIdleCallback.IdleDeadline)) 48 | } 49 | } 50 | send(data = {}) { 51 | let extraData = getExtraData() 52 | let log = {...extraData, ...data} 53 | // 对象的值不能是数字 54 | for (let key in log) { 55 | if (typeof log[key] === 'number') { 56 | log[key] = `${log[key]}` 57 | } 58 | } 59 | console.log('log', log) 60 | 61 | // let body = JSON.stringify({ 62 | // __logs__: [log] 63 | // }) 64 | 65 | // this.xhr.open('POST', this.url, true) 66 | // this.xhr.setRequestHeader('x-log-apiversion', '0.6.0') // 版本号 67 | // this.xhr.setRequestHeader('x-log-bodyrawsize', body.length) // 请求体的大小 68 | // this.xhr.setRequestHeader('Content-Type', 'application/json') // 请求体类型 69 | // // this.xhr.setRequestHeader('x-log-compresstype', 'deflate') 70 | // this.xhr.onload = function () { 71 | // // console.log(this.xhr.response) 72 | // } 73 | // this.xhr.onerror = function (error) { 74 | // // console.log(error) 75 | // } 76 | 77 | // this.xhr.send(body) 78 | 79 | log.APIVersion = '0.6.0' 80 | let url = `http://${project}.${host}/logstores/${logStore}/track_us.gif?${qs.stringify(log)}` 81 | let report = throttle(() => { 82 | console.log('report url', url) 83 | queue.push(url) 84 | }, 2000) 85 | 86 | report() 87 | requestIdleCallback(doLowProMissions) 88 | } 89 | } 90 | 91 | export default new SendTracker() 92 | -------------------------------------------------------------------------------- /src/lib/jsError.js: -------------------------------------------------------------------------------- 1 | import getLastEvent from '../utils/getLastEvent' 2 | import getSelector from '../utils/getSelector' 3 | import tracker from '../utils/tracker' 4 | 5 | export function injectJsError() { 6 | window.addEventListener('error', function (event) { // 错误事件对象 7 | console.log(event) 8 | let lastEvent = getLastEvent() // 获取最后一个交互事件 9 | console.log(lastEvent) 10 | if (event.target&&(event.target.src||event.target.href)) { 11 | let log = { 12 | kind: 'stability', // 监控指标的大类 13 | type: 'error', // 小类型 这是一个错误 14 | errorType: 'resourceError', // 资源加载错误 15 | url: '', // 报错时的访问路径 16 | // message: event.message, // 报错信息 17 | filename: event.target.src || event.target.href, // 报错的文件名 18 | tagName: event.target.tagName, // 报错的标签名 19 | selector: getSelector(event.target) // 代表最后一个操作的元素 20 | } 21 | }else{ 22 | let log = { 23 | kind: 'stability', // 监控指标的大类 24 | type: 'error', // 小类型 这是一个错误 25 | errorType: 'jsError', // js 执行错误 26 | url: '', // 报错时的访问路径 27 | message: event.message, // 报错信息 28 | filename: event.filename, // 报错的文件名 29 | position: `${event.lineno}:${event.colno}`, 30 | stack: getLines(event.error.stack), 31 | selector: lastEvent ? getSelector(lastEvent.path) : ''// 代表最后一个操作的元素 32 | } 33 | 34 | console.log('js log', log) 35 | tracker.send(log) 36 | } 37 | },true) 38 | // 监听全局未捕获的 promise 错误 39 | window.addEventListener('unhandledrejection', function (event) { // promise 错误事件对象 40 | console.log(event) 41 | let lastEvent = getLastEvent() // 获取最后一个交互事件 42 | let message 43 | let filename 44 | let line = 0 45 | let column = 0 46 | let stack = '' 47 | let reason = event.reason 48 | if (typeof reason === 'string') { 49 | message = reason 50 | } else if (typeof reason === 'object') { // 说明是一个错误对象 51 | if (reason.stack) { 52 | let matchResult = reason.stack.match(/at\s+(.+):(\d+):(\d+)/) 53 | filename = matchResult[1] 54 | line = matchResult[2] 55 | column = matchResult[3] 56 | } 57 | message = reason.message 58 | stack = getLines(reason.stack) 59 | } 60 | 61 | let log = { 62 | kind: 'stability', // 监控指标的大类 63 | type: 'error', // 小类型 这是一个错误 64 | errorType: 'promiseError', // promise 执行错误 65 | url: '', // 报错时的访问路径 66 | message: message, // 报错信息 67 | filename: filename, // 报错的文件名 68 | position: `${line}:${column}`, 69 | stack: stack, 70 | selector: lastEvent ? getSelector(lastEvent.path) : ''// 代表最后一个操作的元素 71 | } 72 | 73 | console.log('promise log', log) 74 | tracker.send(log) 75 | }, true) 76 | 77 | function getLines(stack) { 78 | return stack.split('\n').slice(1).map(item => item.replace(/^\s+at\s+/g, '')).join('^') 79 | } 80 | }; 81 | 82 | -------------------------------------------------------------------------------- /src/lib/timing.js: -------------------------------------------------------------------------------- 1 | import tracker from "../utils/tracker"; 2 | import onload from "../utils/onload"; 3 | import getLastEvent from "../utils/getLastEvent"; 4 | import getSelector from "../utils/getSelector"; 5 | 6 | export function timing() { 7 | let FP, FCP, FMP, LCP; 8 | if (PerformanceObserver) { 9 | // 增加性能条目的观察者 10 | new PerformanceObserver((entryList, observer) => { 11 | let perfEntries = entryList.getEntries(); 12 | FMP = perfEntries[0]; 13 | observer.disconnect(); // 停止观察 14 | }).observe({ entryTypes: ["element"] }); // 观察页面中有意义的元素 15 | 16 | new PerformanceObserver((entryList, observer) => { 17 | let perfEntries = entryList.getEntries(); 18 | LCP = perfEntries[0]; 19 | observer.disconnect(); // 停止观察 20 | }).observe({ entryTypes: ["largest-contentful-paint"] }); // 观察页面中最大内容绘制的元素 21 | 22 | new PerformanceObserver((entryList, observer) => { 23 | let firstInput = entryList.getEntries()[0]; 24 | console.log("FID", firstInput); 25 | if (firstInput) { 26 | // processingStart 开始处理的时间 - startTime 开始点击、输入的时间,差值就是处理的延迟 27 | let inputDelay = firstInput.processingStart - firstInput.startTime; 28 | let duration = firstInput.duration; // 处理的耗时 29 | if (inputDelay > 0 || duration > 0) { 30 | // 说明有延迟,需要记录 31 | let lastEvent = getLastEvent(); 32 | let inputDelayLog = { 33 | kind: "experience", 34 | type: "firstInputDelay", // 首次输入延迟 35 | inputDelay, // 延迟的时间 36 | duration, // 处理的时间 37 | startTime: firstInput.startTime, 38 | selector: lastEvent 39 | ? getSelector(lastEvent.path || lastEvent.target) 40 | : "", 41 | }; 42 | tracker.send(inputDelayLog); 43 | } 44 | } 45 | observer.disconnect(); // 停止观察 46 | }).observe({ type: "first-input", buffered: true }); // 用户第一次交互,点击页面、输入内容 47 | } 48 | 49 | onload(function () { 50 | setTimeout(() => { 51 | // 发送各个阶段的时间数据 52 | const { 53 | fetchStart, 54 | connectStart, 55 | connectEnd, 56 | requestStart, 57 | responseStart, 58 | responseEnd, 59 | domLoading, 60 | domInteractive, 61 | domContentLoadedEventStart, 62 | domContentLoadedEventEnd, 63 | loadEventStart, 64 | } = performance.timing; 65 | let log = { 66 | kind: "experience", // 用户体验指标 67 | type: "timing", // 统计各个阶段的时间 68 | connectTime: connectEnd - connectStart, // 连接时间 69 | ttfbTime: responseStart - requestStart, // 首字节时间 70 | responseTime: responseEnd - responseStart, // 响应时间 71 | parseDOMTime: loadEventStart - domLoading, // DOM解析时间 72 | domContentLoadedTime: 73 | domContentLoadedEventEnd - domContentLoadedEventStart, // DOMContentLoaded事件时间 74 | timeToInteractive: domInteractive - fetchStart, // 可交互时间 75 | loadTime: loadEventStart - fetchStart, // 页面加载时间 76 | }; 77 | tracker.send(log); 78 | 79 | // 发送性能数据 80 | FP = performance.getEntriesByName("first-paint")[0]; 81 | FCP = performance.getEntriesByName("first-contentful-paint")[0]; 82 | console.log("FP", FP); 83 | console.log("FCP", FCP); 84 | console.log("FMP", FMP); 85 | console.log("LCP", LCP); 86 | let paintLog = { 87 | kind: "experience", 88 | type: "paint", 89 | firstPaint: FP ? FP.startTime : 0, 90 | firstContentfulPaint: FCP ? FCP.startTime : 0, 91 | firstMeaningfulPaint: FMP ? FMP.startTime : 0, 92 | largestContentfulPaint: LCP ? LCP.startTime : 0, 93 | }; 94 | tracker.send(paintLog); 95 | }, 3000); 96 | }); 97 | } 98 | -------------------------------------------------------------------------------- /前端监控文档.md: -------------------------------------------------------------------------------- 1 | # 前端监控 2 | 3 | ## 1.前端监控的原因 4 | 5 | - 更快发现问题和解决问题 6 | - 做产品的决策依据 7 | - 提升前端工程师的技术深度和广度,打造简历亮点 8 | - 为业务扩展提供更多可能性 9 | 10 | ## 2.前端监控目标 11 | 12 | 1. 稳定性 stability 13 | 14 | | 错误名称 | 备注 | 15 | | -------- | ---------------------------- | 16 | | js 错误 | js 执行错误或者 promise 异常 | 17 | | 资源错误 | script、link 等资源加载异常 | 18 | | 接口错误 | ajax 或 fetch 请求接口异常 | 19 | | 白屏 | 页面空白 | 20 | 21 | 2. 用户体验 experience 22 | 23 | | 名称 | 备注 | 24 | | ----------------------------------------- | ------------------------------------------------------------ | 25 | | 加载时间 | 各个阶段的加载时间 | 26 | | TTFB(time to first byte)首字节时间 | 浏览器发起第一个请求到数据返回第一个字节所消耗的时间,这个时间包含了网络请求时间、后端处理时间 | 27 | | FP(First Paint)首次绘制 | 包括任何用户自定义的背景绘制,它是将第一个像素点绘制到屏幕的时刻 | 28 | | FCP(First Content Paint)首次内容绘制 | 浏览器将第一个 DOM 渲染到屏幕的时间,可以是任何文本、图像、SVG 等内容的时间 | 29 | | FMP(First Meaningful Paint)首次有意义绘制 | 是页面可用性的量度标准 | 30 | | FID(First Input Delay)首次输入延迟 | 用户首次和页面交互到页面响应交互的时间 | 31 | | 卡顿 | 超过 50ms 的长任务 | 32 | 33 | 3. 业务 business 34 | 35 | | 名称 | 备注 | 36 | | -------------- | -------------------------------- | 37 | | PV | page view 页面浏览量或点击量 | 38 | | UV | 访问某个站点的不同 IP 地址的人数 | 39 | | 页面的停留时间 | 用户在每一个页面的停留时间 | 40 | 41 | 42 | 43 | ## 3.前端监控流程 44 | 45 | - 前端埋点 46 | - 数据上报 47 | - 分析和计算 将采集道德数据进行加工汇总 48 | - 可视化展示 将数据按各种维度进行展示 49 | - 监控报警 发现问题后按一定的条件触发报警 50 | 51 | ```mermaid 52 | graph LR 53 | A[埋点] --> B[数据采集]; 54 | B[数据采集] --> C[数据建模存储]; 55 | C[数据建模存储] --> D[数据传输 实时&批量]; 56 | D[数据传输 实时&批量] --> E[数据统计 分析&挖掘]; 57 | E[数据统计 分析&挖掘] --> F[数据可视化 反馈] 58 | F[数据可视化 反馈] -.-> A[埋点] 59 | E[数据统计 分析&挖掘] --> G[报告和报警] 60 | G[报告和报警] -.-> A[埋点] 61 | ``` 62 | 63 | 前端需要关注的:埋点、数据采集 64 | 65 | 66 | 67 | ### 常见的埋点方案 68 | 69 | 1. 代码埋点 70 | 71 | - 以嵌入代码的形式进行埋点,比如需要监控用户的点击事件,会选择在用户点击是,插入一段代码,保存这个监听行为或者直接将监听行为以某一种数据格式直接传递给服务器端 72 | - 优点:可以在任意时刻,精确的发送或保存所需要的数据信息 73 | - 缺点:工作量较大 74 | 75 | 2. 可视化埋点 76 | 77 | - 通过可视化交互的手段,代替代码埋点 78 | - 将业务代码和埋点代码分离,提供一个可视化交互的页面,输入为业务代码,通过这个可视化系统,可以在业务代码中自定义的增加埋点事件等等,最后数据的代码耦合了业务代码和埋点代码 79 | - 可视化埋点就是用系统来代替手工插入埋点代码 80 | 81 | 3. 无痕埋点 82 | 83 | - 前端的任意一个事件都被绑定一个标识,所有的事件都被记录下来 84 | - 通过定期上传记录文件,配合文件解析,解析出来想要的数据,生成可视化报告供专业人员分析 85 | - 优点:采集全量数据,不会出现漏埋和误埋等现象 86 | - 缺点:给数据传输和服务器增加压力,也无法灵活定制数据结构 87 | 88 | 89 | 90 | ## 4.编写监控采集脚本 91 | 92 | ### 开通日志服务 93 | 94 | - 日志服务(Log Service 简称 SLS)是针对日志类数据一站式服务,用户无需开发就能快捷完成数据采集、消费、投递以及查询分析等功能,帮助提升运维、运营效率,建立 DT 时代海量日志处理能力 95 | - 日志服务帮助文档 96 | - Web Tracking 97 | 98 | ### 4.1 监控错误 99 | 100 | #### 错误分类 101 | 102 | - JS 错误 103 | - JS 错误 104 | - Promise 异常 105 | - 资源异常 106 | - 监听 error 107 | 108 | #### 数据结构设计 109 | 110 | 1. jsError 111 | 112 | ```js 113 | { 114 | "title": "前端监控系统", // 页面标题 115 | "url": "http://localhost:8080", // 页面URL 116 | "timestamp": "1653839212233", // 访问时间戳 117 | "userAgent": "Chrome", // 用户浏览器类型 118 | "kind": "stability", // 大类 119 | "type": "error", // 小类 120 | "errorType": "jsError", // 错误类型 121 | "message": "Uncaught TypeError: Cannot set property 'error' of undefined", // 类型详情 122 | "filename": "http://localhost:8080/", // 访问的文件名 123 | "position": "0:0", // 行列信息 124 | "stack": "btnClick (http://localhost:8080/:20:39)^HTMLInputElement.onclick (http://localhost:8080/:14:72)", // 堆栈信息 125 | "selector": "HTML BODY #container .content INPUT" // 选择器 126 | } 127 | ``` 128 | 129 | 130 | 131 | 2. promiseError 132 | 133 | ```js 134 | { 135 | "title": "前端监控系统", // 页面标题 136 | "url": "http://localhost:8080", // 页面URL 137 | "timestamp": "1653839212233", // 访问时间戳 138 | "userAgent": "Chrome", // 用户浏览器类型 139 | "kind": "stability", // 大类 140 | "type": "error", // 小类 141 | "errorType": "promiseError", // 错误类型 142 | "message": "someVar is not defined", // 类型详情 143 | "filename": "http://localhost:8080/", // 访问的文件名 144 | "position": "24:29", // 行列信息 145 | "stack": "http://localhost:8080/:24:29^new Promise ()^btnPromiseClick (http://localhost:8080/:23:13)^HTMLInputElement.onclick (http://localhost:8080/:14:72)", // 堆栈信息 146 | "selector": "HTML BODY #container .content INPUT" // 选择器 147 | } 148 | ``` 149 | 150 | 151 | 152 | 3. resourceError 153 | 154 | ```js 155 | { 156 | "title": "前端监控系统", 157 | "url": "http://localhost:8080", 158 | "timestamp": "1653839212233", 159 | "userAgent": "Chrome", 160 | "kind": "stability", 161 | "type": "error", 162 | "errorType": "resourceError", 163 | "filename": "http://localhost:8080/someError.js", 164 | "tagName": "SCRIPT", 165 | "selector": "HTML BODY SCRIPT" 166 | } 167 | ``` 168 | 169 | 170 | 171 | 172 | 173 | ### 4.2 监控接口 174 | 175 | #### 数据结构设计 176 | 177 | ```js 178 | { 179 | "title": "前端监控系统", // 标题 180 | "url": "http://localhost:8080/", // url 181 | "timestamp": "1653839212233", // timestamp 182 | "userAgent": "Chrome", // 浏览器版本 183 | "kind": "stability", // 大类 184 | "type": "xhr", // 小类 185 | "eventType": "load", // 事件类型 186 | "pathname": "/success", // 路径 187 | "status": "200-OK", // 状态码 188 | "duration": "7", // 持续时间 189 | "response": "{\"id\":1}", // 响应内容 190 | "params": "" // 参数 191 | } 192 | ``` 193 | 194 | 例如 195 | 196 | ```js 197 | { 198 | "title": "前端监控系统", // 标题 199 | "url": "http://localhost:8080/", // url 200 | "timestamp": "1653839212233", // timestamp 201 | "userAgent": "Chrome", // 浏览器版本 202 | "kind": "stability", // 大类 203 | "type": "xhr", // 小类 204 | "eventType": "load", // 事件类型 205 | "pathname": "/error", // 路径 206 | "status": "500-Internal Server Error", // 状态码 207 | "duration": "7", // 持续时间 208 | "response": "", // 响应内容 209 | "params": "name=zhangsan" // 参数 210 | } 211 | ``` 212 | 213 | 214 | 215 | 216 | 217 | ### 4.3 监控白屏 218 | 219 | #### 数据结构设计 220 | 221 | ```js 222 | { 223 | "title": "前端监控系统", // 标题 224 | "url": "http://localhost:8080/", // url 225 | "timestamp": "1653839212233", // timestamp 226 | "userAgent": "Chrome", // 浏览器版本 227 | "kind": "stability", // 大类 228 | "type": "blank", // 小类 229 | "emptyPoints": "0", // 空白点 230 | "screen": "3840x2160", // 分辨率 231 | "viewPoint": "3840x2160", // 视口 232 | "selector": "HTML BODY #container" // 选择器 233 | } 234 | ``` 235 | 236 | #### 实现要点 237 | 238 | - screen 返回当前 window 的 screen 属性对象,返回当前渲染窗口中和屏幕有关的属性 239 | - innerWidth 只读的 Window 属性 innerWidth 返回以像素为单位的窗口的内部宽度 240 | - innerHeight 窗口的内部高度(布局视口)的高度 241 | - layout_viewport 242 | - elementsFromPoint 方法可以获取到当前视口内指定坐标处,由里到外排列的所有元素 243 | 244 | 245 | 246 | 247 | 248 | ### 4.4 监控加载时间 249 | 250 | - PerformanceTiming 251 | - DOMContentLoaded 252 | - FMP 253 | 254 | https://github.com/pfan123/Articles/issues/87 255 | 256 | #### 阶段含义 257 | 258 | | 字段 | 含义 | 259 | | -------------------------- | ------------------------------------------------------------ | 260 | | navigationStart | 初始化页面,在同一个浏览器上下文中前一个页面 unload 的时间戳,如果没有前一个页面的 unload,则与 fetchStart 值相等 | 261 | | redirectStart | 第一个 HTTP 重定向发生的时间,有跳转且是同域的重定向,否则为 0 | 262 | | redirectEnd | 最后一个重定向完成时的时间,否则为 0 | 263 | | fetchStart | 浏览器准备好使用 http 请求获取文档的时间,发生在检查缓存之前 | 264 | | domainLookupStart | DNS 域名开始查询的时间,如果有本地的缓存或 keep-alive 则时间为 0 | 265 | | domainLookupEnd | DNS 域名结束查询的时间 | 266 | | connectStart | TCP 开始建立连接的时间,如果是持久连接,则与 fetchStart 值相等 | 267 | | secureConnectionStart | https 连接开始的时间,如果不是安全连接,则为 0 | 268 | | connectEnd | TCP 完成握手的时间,如果是持久连接则与 fetchStart 值相等 | 269 | | requestStart | HTTP 请求读取真实文档开始的时间,包括从本地缓存读取 | 270 | | requestEnd | HTTP 请求读取真实文档结束的时间,包括从本地缓存读取 | 271 | | responseStart | 返回浏览器从服务器收到(或从本地缓存读取)第一个字节时的 Unix 毫秒时间戳 | 272 | | responseEnd | 返回浏览器从服务器收到(或从本地缓存、本地资源读取)最后一个字节时的 Unix 毫秒时间戳 | 273 | | unloadEventStart | 前一个页面的 unload 的时间戳,如果没有则为 0 | 274 | | unloadEventEnd | 与 unloadEventStart 相对应,返回的是 unload 函数执行完成的时间戳 | 275 | | domLoading | 返回当前网页 DOM 结构开始解析时的时间戳,此时 document.readyState 变成 loading,并将抛出 readyStateChange 事件 | 276 | | domInteractive | 返回当前网页 DOM 结构结束解析、开始加载内嵌资源时的时间戳,document.readyState 变成 interactive,并将抛出 readyStateChange 事件(注意:只是 DOM 树解析完成,此时并没有开始加载网页内的资源) | 277 | | domContentLoadedEventStart | 网页 domContentLoaded 事件发生的时间 | 278 | | domContentLoadedEventEnd | 网页 domContentLoaded 事件脚本执行完毕的时间,domReady 的时间 | 279 | | domComplete | DOM 树解析完成,且资源也准备就绪的时间,document.readyState 变成 complete,并将抛出 readyStateChange 事件 | 280 | | loadEventStart | load 事件发送给文档,也就是 load 回调函数开始执行的时间 | 281 | | loadEventEnd | load 回调函数执行完成的时间 | 282 | 283 | 284 | 285 | #### 阶段计算 286 | 287 | | 字段 | 描述 | 计算方式 | 意义 | 288 | | ---------------- | ------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------ | 289 | | unload | 前一个页面卸载耗时 | unloadEventEnd - unloadEventStart | - | 290 | | redirect | 重定向耗时 | redirectEnd - redirectStart | 重定向的时间 | 291 | | appCache | 缓存耗时 | domainLookupStart - fetchStart | 读取缓存的时间 | 292 | | dns | DNS 解析耗时 | domainLookupEnd - domainLookupStart | 可观察域名解析服务是否正常 | 293 | | tcp | TCP 连接耗时 | connectEnd - connectStart | 建立连接的耗时 | 294 | | ssl | SSL 安全连接耗时 | connectEnd - secureConnectionStart | 反映数据安全连接建立耗时 | 295 | | ttfb | Time to First Byte(TTFB) 网络请求耗时 | responseStart - requestStart | TTFB 是发出页面请求到接收到应答数据第一个字节所花费的毫秒数 | 296 | | response | 响应数据传输耗时 | responseEnd - responseStart | 观察网络是否正常 | 297 | | dom | DOM 解析耗时 | domInteractive - responseEnd | 观察 DOM 结构是否合理,是否有 JS 阻塞页面解析 | 298 | | dcl | DOMContentLoaded 事件耗时 | domContentLoadedEventEnd - domContentLoadedEventStart | 当 HTML 文档被完全加载和解析完成之后,DOMContentLoaded 事件被触发,无需等待样式表、图像和子框架的完成加载 | 299 | | resources | 资源加载耗时 | domComplete - domContentLoadedEventEnd | 可观察文档流是否过大 | 300 | | domReady | DOM 阶段渲染耗时 | domContentLoadedEventEnd - fetchStart | DOM 树和页面资源加载完成时间,会触发 domContentLoaded 事件 | 301 | | 首次渲染耗时 | 首次渲染耗时 | responseEnd - fetchStart | 加载文档到看到第一帧非空图像的时间,也就是白屏时间 | 302 | | 首次可交互时间 | 首次可交互时间 | domInteractive - fetchStart | DOM 解析完成时间,此时 document.readyState 为 interactive | 303 | | 首包时间耗时 | 首包时间 | responseStart - domainLookupStart | DNS 解析到响应返回给浏览器第一个字节的时间 | 304 | | 页面完全加载时间 | 页面完全加载时间 | loadEventStart - fetchStart | | 305 | | onLoad | onLoad 事件耗时 | loadEventEnd - loadEventStart | | 306 | 307 | 308 | 309 | #### 数据结构设计 310 | 311 | ```js 312 | { 313 | "title": "前端监控系统", // 标题 314 | "url": "http://localhost:8080/", // url 315 | "timestamp": "1653839212233", // timestamp 316 | "userAgent": "Chrome", // 浏览器版本 317 | "kind": "experience", // 大类 318 | "type": "timing", // 小类 319 | "connectTime": "0", 320 | "ttfbTime": "1", 321 | "responseTime": "1", 322 | "parseDOMTime": "80", 323 | "domContentLoadedTime": "0", 324 | "timeToInteractive": "88", 325 | "loadTime": "89" 326 | } 327 | ``` 328 | 329 | 330 | 331 | 332 | 333 | ### 4.5 性能指标 334 | 335 | - PerformanceObserver.observe 返回用于观察传入的参数中,指定的性能条目类型的集合。当记录一个指定类型的性能条目时,性能监测对象的回调函数将会被触发 336 | - entryType 337 | - paint-timing 338 | - event-timing 339 | - LCP 340 | - FMP 341 | - time-to-interactive 342 | 343 | #### 指标含义 344 | 345 | | 字段 | 描述 | 备注 | 346 | | ---- | ------------------------------------- | ------------------------------------------------------------ | 347 | | FP | First Paint 首次绘制 | 包括了任何用户自定义的背景绘制,是指首先将像素绘制到屏幕的时刻 | 348 | | FCP | First Contentful Paint 首次内容绘制 | 是浏览器将第一个 DOM 渲染到屏幕的时间,可能是文本、图像、SVG 等,这其实就是白屏时间 | 349 | | FMP | First Meaningful Paint 首次有意义绘制 | 页面有意义的内容渲染的时间 | 350 | | LCP | Largest Contentful Paint 最大内容渲染 | 代表在 viewport 中最大的页面元素加载的时间 | 351 | | DCL | DomContentLoaded DOM 加载完成 | 当 HTML 文档被完全加载和解析完成之后,DOMContentLoaded 事件被触发,无需等待样式表、图像和子框架的完成加载 | 352 | | L | onLoad | 当依赖的资源全部加载完毕之后才会触发 | 353 | | TTI | Time to Interactive 可交互时间 | 用于标记应用已进行视觉渲染并能可靠响应用户输入的时间点 | 354 | | FID | First Input Delay 首次输入延迟 | 用户首次和页面交互(单击链接、点击按钮等)到页面响应交互的时间 | 355 | 356 | 357 | 358 | #### 数据结构设计 359 | 360 | - paint 361 | 362 | ```js 363 | { 364 | "title": "前端监控系统", // 标题 365 | "url": "http://localhost:8080/", // url 366 | "timestamp": "1653839212233", // timestamp 367 | "userAgent": "Chrome", // 浏览器版本 368 | "kind": "experience", // 大类 369 | "type": "paint", // 小类 370 | "firstPaint": "102", 371 | "firstContentPaint": "2130", 372 | "firstMeaningfulPaint": "2130", 373 | "largestContentfulPaint": "2130" 374 | } 375 | ``` 376 | 377 | - firstInputDelay 378 | 379 | ```js 380 | { 381 | "title": "前端监控系统", // 标题 382 | "url": "http://localhost:8080/", // url 383 | "timestamp": "1653839212233", // timestamp 384 | "userAgent": "Chrome", // 浏览器版本 385 | "kind": "experience", // 大类 386 | "type": "firstInputDelay", // 小类 387 | "inputDelay": "3", 388 | "duration": "8", 389 | "startTime": "4812.123123123213", 390 | "selector": "HTML BODY #container .content H1" 391 | } 392 | ``` 393 | 394 | 395 | 396 | ### 4.6 卡顿 397 | 398 | - 响应用户交互的响应时间如果大于 100ms,用户就会感觉卡顿 399 | 400 | #### 数据结构设计 401 | 402 | ```js 403 | { 404 | "title": "前端监控系统", // 标题 405 | "url": "http://localhost:8080/", // url 406 | "timestamp": "1653839212233", // timestamp 407 | "userAgent": "Chrome", // 浏览器版本 408 | "kind": "experience", // 大类 409 | "type": "longTask", // 小类 410 | "eventType": "mouseover", 411 | "startTime": "9331", 412 | "duration": "200", 413 | "selector": "HTML BODY #container .content" 414 | } 415 | ``` 416 | 417 | 418 | 419 | ### 4.7 pv 420 | 421 | - netinfo 422 | - RTT(Round Trip Time) 一个连接的往返时间,即数据发送时刻到接收到确认的时刻的差值 423 | - navigator.sendBeacon() 方法可用于通过 HTTP 将少量数据差异传输到 Web 服务器 424 | 425 | #### 数据结构设计 426 | 427 | ```js 428 | { 429 | "title": "前端监控系统", // 标题 430 | "url": "http://localhost:8080/", // url 431 | "timestamp": "1653839212233", // timestamp 432 | "userAgent": "Chrome", // 浏览器版本 433 | "kind": "business", // 大类 434 | "type": "pv", // 小类 435 | "effectiveType": "4g", 436 | "rtt": "50", 437 | "screen": "2049x1152" 438 | } 439 | ``` 440 | 441 | --------------------------------------------------------------------------------