跳到主要内容

5.4-网站监控

Create by fall on 06 Feb 2025
Recently revised in 31 Oct 2025

网站监控

监控的目的:

  • 主动发现问题和解决问题
  • 做产品的决策依据
  • 为业务扩展提供了更多可能性
  • 提升前端工程师的技术深度和广度(简历亮点)

监控方向

系统稳定性、健壮性

  • 脚本执行错误
  • 资源错误(加载异常
  • 请求异常

用户体验

  • FID:first input delay(首次输入延迟)
  • FCP:首屏渲染时间,一般需要在一秒内
  • LCP:Largest ContentFul Paint (最大内容绘制)
  • TTI:Time to Interreactive(可以使用页面中的内容的时间)

业务

  • 页面浏览和点击量(pageview)
  • 访问页面不同 ip 的人数(unique visitor)
  • 停留时间

监控实现

前端埋点、数据上报、加工汇总、可视化展示、监控预警

捕获 js 异常

// 一般 JS 运行时错误使用 window.onerror 捕获处理
window.addEventListener(
"error",
function (event) {
let lastEvent = getLastEvent();
// 有 e.target.src(href) 的认定为资源加载错误
if (event.target && (event.target.src || event.target.href)) {
tracker.send({
// 资源加载错误
type: "error", // resource
subType:'resource-error'
// ...
});
} else {
tracker.send({
type: "error", //error
errorType: "js-error",
});
}
},
true
); // true代表在捕获阶段调用,false代表在冒泡阶段捕获,使用 true 或 false 都可以

捕获 promise 异常

//当 Promise 被 reject 且没有 reject 处理器的时候,会触发 unhandledrejection 事件
window.addEventListener("unhandledrejection",
function (event) {
let lastEvent = getLastEvent();
let message = "";
let line = 0;
let column = 0;
let file = "";
let stack = "";
if (typeof event.reason === "string") {
message = event.reason;
} else if (typeof event.reason === "object") {
message = event.reason.message;
}
let reason = event.reason;
if (typeof reason === "object") {
if (reason.stack) {
var matchResult = reason.stack.match(/at\s+(.+):(\d+):(\d+)/);
if (matchResult) {
file = matchResult[1];
line = matchResult[2];
column = matchResult[3];
}
stack = getLines(reason.stack);
}
}
tracker.send({
//未捕获的promise错误
kind: "stability", //稳定性指标
type: "error", //jsError
errorType: "promiseError", //unhandledrejection
message: message, //标签名
filename: file,
position: line + ":" + column, //行列
stack,
selector: lastEvent
? getSelector(lastEvent.path || lastEvent.target)
: "",
});
},
true
); // true代表在捕获阶段调用,false代表在冒泡阶段捕获,使用true或false都可以

页面是否白屏

浏览器上面的地址栏已经显示完整的 URL,但是页面未能渲染,只有白色的空白页面。

import tracker from "../util/tracker";
import onload from "../util/onload";
function getSelector(element) {
var selector;
if (element.id) {
selector = `#${element.id}`;
} else if (element.className && typeof element.className === "string") {
selector =
"." +
element.className
.split(" ")
.filter(function (item) {
return !!item;
})
.join(".");
} else {
selector = element.nodeName.toLowerCase();
}
return selector;
}
export function blankScreen() {
const wrapperSelectors = ["body", "html", "#container", ".content"];
let emptyPoints = 0;
function isWrapper(element) {
let selector = getSelector(element);
if (wrapperSelectors.indexOf(selector) >= 0) {
emptyPoints++;
}
}
onload(function () {
let xElements, yElements;
debugger;
for (let i = 1; i <= 9; i++) {
xElements = document.elementsFromPoint(
(window.innerWidth * i) / 10,
window.innerHeight / 2
);
yElements = document.elementsFromPoint(
window.innerWidth / 2,
(window.innerHeight * i) / 10
);
isWrapper(xElements[0]);
isWrapper(yElements[0]);
}
if (emptyPoints >= 0) {
let centerElements = document.elementsFromPoint(
window.innerWidth / 2,
window.innerHeight / 2
);
tracker.send({
kind: "stability",
type: "blank",
emptyPoints: "" + emptyPoints,
screen: window.screen.width + "x" + window.screen.height,
viewPoint: window.innerWidth + "x" + window.innerHeight,
selector: getSelector(centerElements[0]),
});
}
});
}
// window.innerWidth 去除工具条与滚动条的窗口宽度
// window.innerHeight 去除工具条与滚动条的窗口高度

性能相关指标

浏览器加载的各个阶段含义

网页加载阶段

image-20251030193820469

image.png

相关工具和 API

字段含义
navigationStart初始化页面,在同一个浏览器上下文中前一个页面unload的时间戳,如果没有前一个页面的unload,则与fetchStart值相等
redirectStart第一个HTTP重定向发生的时间,有跳转且是同域的重定向,否则为0
redirectEnd最后一个重定向完成时的时间,否则为0
fetchStart浏览器准备好使用http请求获取文档的时间,这发生在检查缓存之前
domainLookupStartDNS域名开始查询的时间,如果有本地的缓存或keep-alive则时间为0
domainLookupEndDNS域名结束查询的时间
connectStartTCP开始建立连接的时间,如果是持久连接,则与fetchStart值相等
secureConnectionStarthttps 连接开始的时间,如果不是安全连接则为0
connectEndTCP完成握手的时间,如果是持久连接则与fetchStart值相等
requestStartHTTP请求读取真实文档开始的时间,包括从本地缓存读取
requestEndHTTP请求读取真实文档结束的时间,包括从本地缓存读取
responseStart返回浏览器从服务器收到(或从本地缓存读取)第一个字节时的Unix毫秒时间戳
responseEnd返回浏览器从服务器收到(或从本地缓存读取,或从本地资源读取)最后一个字节时的 Unix 毫秒时间戳
unloadEventStart前一个页面的 unload 的时间戳 如果没有则为 0
unloadEventEndunloadEventStart相对应,返回的是unload函数执行完成的时间戳
domLoading返回当前网页DOM结构开始解析时的时间戳,此时document.readyState变成loading,并将抛出readyStateChange事件
domInteractive返回当前网页DOM结构结束解析、开始加载内嵌资源时时间戳,document.readyState 变成interactive,并将抛出readyStateChange事件(注意只是DOM树解析完成,这时候并没有开始加载网页内的资源)
domContentLoadedEventStart网页 domContentLoaded 事件发生的时间
domContentLoadedEventEnd网页 domContentLoaded 事件脚本执行完毕的时间,domReady 的时间
domCompleteDOM 树解析完成,且资源也准备就绪的时间,document.readyState变成complete.并将抛出 readystatechange 事件
loadEventStartload 事件发送给文档,也即load回调函数开始执行的时间
loadEventEndload 回调函数执行完成的时间

常用指标

字段描述备注解释
FPFirst Paint(首次绘制)包括了任何用户自定义的背景绘制,它是首先将像素绘制到屏幕的时刻表示浏览器从开始请求网站到屏幕渲染第一个像素点的时间
FCPFirst Content Paint(首次内容绘制)是浏览器将第一个 DOM 渲染到屏幕的时间,可能是文本、图像、SVG 等,这其实就是白屏时间表示浏览器渲染出第一个内容的时间,这个内容可以是文本、图片或 SVG 元素等等,不包括 iframe 和白色背景的canvas 元素
FMPFirst Meaningful Paint(首次有意义绘制)页面有意义的内容渲染的时间
LCPLargest Contentful Paint(最大内容渲染)代表在 viewport 中最大的页面元素加载的时间标记了渲染出最大文本或图片的时间
DCLDomContentLoaded(DOM加载完成)当 HTML 文档被完全加载和解析完成之后, DOMContentLoaded 事件被触发,无需等待样式表、图像和子框架的完成加载当初始的 HTML 文档被完全加载和解析完成之后,DOMContentLoaded 事件被触发,而无需等待样式表、图像和子框架的完成加载
LonLoad当依赖的资源全部加载完毕之后才会触发检测一个完全加载的页面,页面的 html、css、js、图片等资源都已经加载完之后才会触发 load 事件
TTITime to Interactive 可交互时间用于标记应用已进行视觉渲染并能可靠响应用户输入的时间点页面从开始加载到主要子资源完成渲染,并能够快速、可靠的响应用户输入所需的时间
FIDFirst Input Delay(首次输入延迟)用户首次和页面交互(单击链接,点击按钮等)到页面响应交互的时间测量加载响应度的一个以用户为中心的重要指标
TBTTotal Blocking Time 总阻塞时间测量 FCP 与 TTI 之间的总时间,这期间,主线程被阻塞的时间过长,无法作出输入响应
SISpeed Index 速度指数表明了网页内容的可见填充速度
CLSCumulative Layout Shift 累积布局偏移指页面上所有元素在页面加载过程中的布局变化。整个页面生命周期内发生的所有意外布局,例如用户在填写表单时,输入框被其他元素挤动的情况,这些布局变化都会影响到用户的体验。页面的CLS指数应该小于 0.1
INPInteraction to Next Paint从输入到下一个画面的时间
TTFBTime To First Byte是指从客户端发出 HTTP 请求到服务端返回第一个字节的时间,反映了服务器响应速度。

阶段计算

字段描述计算方式意义
unload前一个页面卸载耗时unloadEventEnd – unloadEventStart-
redirect重定向耗时redirectEnd – redirectStart重定向的时间
appCache缓存耗时domainLookupStart – fetchStart读取缓存的时间
dnsDNS 解析耗时domainLookupEnd – domainLookupStart可观察域名解析服务是否正常
tcpTCP 连接耗时connectEnd – connectStart建立连接的耗时
sslSSL 安全连接耗时connectEnd – secureConnectionStart反映数据安全连接建立耗时
ttfbTime to First Byte (TTFB)网络请求耗时responseStart – requestStartTTFB 是发出页面请求到接收到应答数据第一个字节所花费的毫秒数
response响应数据传输耗时responseEnd – responseStart观察网络是否正常
domDOM解析耗时domInteractive – responseEnd观察DOM结构是否合理,是否有JS阻塞页面解析
dclDOMContentLoaded 事件耗时domContentLoadedEventEnd – domContentLoadedEventStart当 HTML 文档被完全加载和解析完成之后,DOMContentLoaded 事件被触发,无需等待样式表、图像和子框架的完成加载
resources资源加载耗时domComplete – domContentLoadedEventEnd可观察文档流是否过大
domReadyDOM阶段渲染耗时domContentLoadedEventEnd – fetchStartDOM 树和页面资源加载完成时间,会触发domContentLoaded事件
首次渲染耗时首次渲染耗时responseEnd-fetchStart加载文档到看到第一帧非空图像的时间,也叫白屏时间
首次可交互时间首次可交互时间domInteractive-fetchStartDOM 树解析完成时间,此时document.readyState 为 interactive
首包时间耗时首包时间responseStart-domainLookupStartDNS解析到响应返回给浏览器第一个字节的时间
页面完全加载时间页面完全加载时间loadEventStart - fetchStart-
onLoadonLoad事件耗时loadEventEnd – loadEventStart

数据结构

{
"programName": "前端监控系统",
"type": "performance",
"subType": "FCP",
"message": "someVar is not defined",
"userAgent": "Chrome", // 用户浏览器类型
"deviceInfo":{
"CPU":"",
"GPU":"",
},
}

paint,绘制相关

{
"title": "前端监控系统",
"url": "http://localhost:8080/",
"timestamp": "1590828364186",
"userAgent": "chrome",
"kind": "experience",
"type": "paint",
"firstPaint": "102",
"firstContentPaint": "2130",
"firstMeaningfulPaint": "2130",
"largestContentfulPaint": "2130"
}

firstInputDelay

{
"title": "前端监控系统",
"url": "http://localhost:8080/",
"timestamp": "1590828477284",
"userAgent": "chrome",
"kind": "experience",
"type": "firstInputDelay",
"inputDelay": "3",
"duration": "8",
"startTime": "4812.344999983907",
"selector": "HTML BODY #container .content H1"
}

实现

import onload from "../util/onload";
import tracker from "../util/tracker";
import formatTime from "../util/formatTime";
import getLastEvent from "../util/getLastEvent";
import getSelector from "../util/getSelector";
export function timing() {
onload(function () {
setTimeout(() => {
const {
fetchStart,
connectStart,
connectEnd,
requestStart,
responseStart,
responseEnd,
domLoading,
domInteractive,
domContentLoadedEventStart,
domContentLoadedEventEnd,
loadEventStart,
} = performance.timing;
tracker.send({
kind: "experience",
type: "timing",
connectTime: connectEnd - connectStart, //TCP连接耗时
ttfbTime: responseStart - requestStart, //ttfb
responseTime: responseEnd - responseStart, //Response响应耗时
parseDOMTime: loadEventStart - domLoading, //DOM解析渲染耗时
domContentLoadedTime:
domContentLoadedEventEnd - domContentLoadedEventStart, //DOMContentLoaded事件回调耗时
timeToInteractive: domInteractive - fetchStart, //首次可交互时间
loadTime: loadEventStart - fetchStart, //完整的加载时间
});
}, 3000);
});
}

关键时间节点通过 window.performance.timing 获取

import tracker from "../utils/tracker";
import onload from "../utils/onload";
import getLastEvent from "../utils/getLastEvent";
import getSelector from "../utils/getSelector";

export function timing() {
let FMP, LCP;
// 增加一个性能条目的观察者
new PerformanceObserver((entryList, observer) => {
const perfEntries = entryList.getEntries();
FMP = perfEntries[0];
observer.disconnect(); // 不再观察了
}).observe({ entryTypes: ["element"] }); // 观察页面中有意义的元素
// 增加一个性能条目的观察者
new PerformanceObserver((entryList, observer) => {
const perfEntries = entryList.getEntries();
const lastEntry = perfEntries[perfEntries.length - 1];
LCP = lastEntry;
observer.disconnect(); // 不再观察了
}).observe({ entryTypes: ["largest-contentful-paint"] }); // 观察页面中最大的元素
// 增加一个性能条目的观察者
new PerformanceObserver((entryList, observer) => {
const lastEvent = getLastEvent();
const firstInput = entryList.getEntries()[0];
if (firstInput) {
// 开始处理的时间 - 开始点击的时间,差值就是处理的延迟
let inputDelay = firstInput.processingStart - firstInput.startTime;
let duration = firstInput.duration; // 处理的耗时
if (inputDelay > 0 || duration > 0) {
tracker.send({
kind: "experience", // 用户体验指标
type: "firstInputDelay", // 首次输入延迟
inputDelay: inputDelay ? formatTime(inputDelay) : 0, // 延迟的时间
duration: duration ? formatTime(duration) : 0,
startTime: firstInput.startTime, // 开始处理的时间
selector: lastEvent
? getSelector(lastEvent.path || lastEvent.target)
: "",
});
}
}
observer.disconnect(); // 不再观察了
}).observe({ type: "first-input", buffered: true }); // 第一次交互

// 刚开始页面内容为空,等页面渲染完成,再去做判断
onload(function () {
setTimeout(() => {
const {
fetchStart,
connectStart,
connectEnd,
requestStart,
responseStart,
responseEnd,
domLoading,
domInteractive,
domContentLoadedEventStart,
domContentLoadedEventEnd,
loadEventStart,
} = window.performance.timing;
// 发送时间指标
tracker.send({
kind: "experience", // 用户体验指标
type: "timing", // 统计每个阶段的时间
connectTime: connectEnd - connectStart, // TCP连接耗时
ttfbTime: responseStart - requestStart, // 首字节到达时间
responseTime: responseEnd - responseStart, // response响应耗时
parseDOMTime: loadEventStart - domLoading, // DOM解析渲染的时间
domContentLoadedTime:
domContentLoadedEventEnd - domContentLoadedEventStart, // DOMContentLoaded事件回调耗时
timeToInteractive: domInteractive - fetchStart, // 首次可交互时间
loadTime: loadEventStart - fetchStart, // 完整的加载时间
});
// 发送性能指标
let FP = performance.getEntriesByName("first-paint")[0];
let FCP = performance.getEntriesByName("first-contentful-paint")[0];
console.log("FP", FP);
console.log("FCP", FCP);
console.log("FMP", FMP);
console.log("LCP", LCP);
tracker.send({
kind: "experience",
type: "paint",
firstPaint: FP ? formatTime(FP.startTime) : 0,
firstContentPaint: FCP ? formatTime(FCP.startTime) : 0,
firstMeaningfulPaint: FMP ? formatTime(FMP.startTime) : 0,
largestContentfulPaint: LCP
? formatTime(LCP.renderTime || LCP.loadTime)
: 0,
});
}, 3000);
});
}

卡顿

响应用户交互的响应时间如果大于 100ms,用户就会感觉卡顿

数据设计

{
"title": "前端监控系统",
"url": "http://localhost:8080/",
"timestamp": "1590828656781",
"userAgent": "chrome",
"kind": "experience",
"type": "longTask",
"eventType": "mouseover",
"startTime": "9331",
"duration": "200",
"selector": "HTML BODY #container .content"
}

实现

  • PerformanceObserver API
  • entry.duration > 100 判断大于 100ms,即可认定为长任务
  • 使用 requestIdleCallback 上报数据
import tracker from "../util/tracker";
import formatTime from "../util/formatTime";
import getLastEvent from "../util/getLastEvent";
import getSelector from "../util/getSelector";
export function longTask() {
new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.duration > 100) {
let lastEvent = getLastEvent();
requestIdleCallback(() => {
tracker.send({
kind: "experience",
type: "longTask",
eventType: lastEvent.type,
startTime: formatTime(entry.startTime), // 开始时间
duration: formatTime(entry.duration), // 持续时间
selector: lastEvent
? getSelector(lastEvent.path || lastEvent.target)
: "",
});
});
}
});
}).observe({ entryTypes: ["longtask"] });
}

用户行为监控

PV、UV、用户停留时间

PV(page view)是页面浏览量,UV(Unique visitor)用户访问量。PV 只要访问一次页面就算一次,UV 同一天内多次访问只算一次。

对于前端来说,只要每次进入页面上报一次 PV 就行,UV 的统计放在服务端来做,主要是分析上报的数据来统计得出 UV。

数据设计

{
"title": "前端监控系统",
"url": "http://localhost:8080/",
"timestamp": "1590829304423",
"userAgent": "chrome",
"kind": "business",
"type": "pv",
"effectiveType": "4g",
"rtt": "50",
"screen": "2049x1152"
}

实现

import tracker from "../util/tracker";
export function pv() {
tracker.send({
kind: "business",
type: "pv",
startTime: performance.now(),
pageURL: getPageURL(),
referrer: document.referrer,
uuid: getUUID(),
});
let startTime = Date.now();
window.addEventListener(
"beforeunload",
() => {
let stayTime = Date.now() - startTime;
tracker.send({
kind: "business",
type: "stayTime",
stayTime,
pageURL: getPageURL(),
uuid: getUUID(),
});
},
false
);
}

监控上报

1*1像素的透明GIF上报

由于浏览器在请求图片时会将请求地址和所有的查询参数一起发送到服务器,因此通过在请求地址中添加查询参数,可以实现数据的上报功能

使用图片上传的好处

  1. 防止跨域 图片的src属性并不会跨域,并且同样可以发起请求
  2. 防止阻塞页面加载,影响用户体验 图片不用真实插入DOM中,即可发送请求

为什么是1*1像素的透明GIF

  1. 体积小 从图片的体积上来说最小的BMP文件需要74个字节,PNG需要67个字节,而合法的GIF,只需要43个字节。同样的响应,GIF可以比BMP节约41%的流量,比PNG节约35%的流量
  2. 1x1像素是最小的合法图片
  3. 透明的图片不会影响页面本身展示的效果
  4. 透明色的图片不用存储色彩空间数据,可以节约体积

参考文章

作者链接
miracle90https://github.com/miracle90/monitor