🚀前端监控全链路解析+全栈实现🚀
前言
想一想,现在老板让你做一个性能优化,你怎么知道需要优化哪些地方、哪些指标,以及做完优化之后怎么判断是否有效呢?这个时候你就需要一个性能监控。
再想想,生产有某个地方用户反馈点击没反应,这个时候应该如何排查呢?成熟的错误日志平台会给你帮助。
假设说你做的是一个电商平台,怎么更直观的看到今天有多少用户点击了下单按钮跳到购物车了呢?也许有一个自定义埋点监控会更好。
所以本文会围绕着 3
个与前端息息相关的方向展开,即:
- 性能监控
- 自定义埋点监控
- 错误监控(下篇会详细讲述)
包含了监控 SDK
设计、数据上报、数据存储、数据可视化分析,是一次全栈实战的体验。
本文比较长,建议点赞收藏🐶。希望耐心看完之后对你有帮助,如果我有哪里说得不对的,也恳请各位大佬批评指教。
PS:我记得有一次面试被面试官问过如何搭建一个监控平台,希望看完本文之后你再遇到类似的面试题目可以游刃有余的回答。
性能监控
对于性能监控,我们把它分为两类,一类是一些首屏加载指标,比如加载时间、 FCP
等;另一类是运行时的指标,比如 FPS
、内存占用、网络指标等。
首屏指标浅析
Navigation Timing API
是一个由 W3C
标准化的 Web API
,提供了关于页面加载和导航过程中各个时间点的详细性能数据。这些数据可以帮助我们分析和优化页面加载性能。以下是 Navigation Timing API
中一些主要的属性和方法:
主要的时间戳属性:
navigationStart
:浏览器准备加载新页面的起始时间。unloadEventStart
/unloadEventEnd
:前一个页面的unload
事件开始和结束的时间。redirectStart
/redirectEnd
:重定向开始和结束的时间。fetchStart
:浏览器准备从服务器获取第一个字节的时间。domainLookupStart
/domainLookupEnd
:DNS 查询开始和结束的时间。connectStart
/connectEnd
:浏览器与服务器建立连接的开始和结束的时间。secureConnectionStart
:安全连接开始的时间。requestStart
/responseStart
/responseEnd
:请求开始、收到响应的第一个字节、收到响应的最后一个字节的时间。domLoading
/domInteractive
/domContentLoadedEventStart
/domContentLoadedEventEnd
:DOM 加载开始、可以交互的时间、DOMContentLoaded
事件开始和结束的时间。domComplete
:DOM 加载完成的时间。loadEventStart
/loadEventEnd
:load
事件开始和结束的时间。
通过这些属性,我们可以来计算一些我们比较关心的指标信息:
网络请求指标:
- 请求开始时间:浏览器准备加载新页面到开始请求资源的时间。
- 首字节时间(Time to First Byte, TTFB):从发出请求到收到响应的第一个字节的时间。
- 内容下载时间:开始接收响应数据到接收完所有响应数据的时间。
- 请求完成时间:从开始请求资源到完成接收所有响应数据的时间。
window.addEventListener('load', function() {
const performanceTiming = window.performance.timing;
// 请求开始时间
const requestStartTime = performanceTiming.requestStart - performanceTiming.navigationStart;
console.log('Request Start Time: ' + requestStartTime + ' ms');
// 首字节时间(TTFB)
const ttfb = performanceTiming.responseStart - performanceTiming.requestStart;
console.log('Time to First Byte (TTFB): ' + ttfb + ' ms');
// 内容下载时间
const contentDownloadTime = performanceTiming.responseEnd - performanceTiming.responseStart;
console.log('Content Download Time: ' + contentDownloadTime + ' ms');
// 请求完成时间
const requestCompletionTime = performanceTiming.responseEnd - performanceTiming.requestStart;
console.log('Request Completion Time: ' + requestCompletionTime + ' ms');
});
页面加载时间(Page Load Time):从浏览器加载新页面开始,到页面所有资源,包括图像、脚本等完全加载。
window.addEventListener('load', function() {
const performanceTiming = window.performance.timing;
const pageLoadTime = performanceTiming.loadEventEnd - performanceTiming.navigationStart;
console.log('Page Load Time: ' + pageLoadTime + ' ms');
});
文档加载完成时间(DOMContentLoaded):从浏览器加载新页面开始,到 DOM
内容完全加载并解析完成。
document.addEventListener('DOMContentLoaded', function() {
const performanceTiming = window.performance.timing;
const domContentLoadedTime = performanceTiming.domContentLoadedEventEnd - performanceTiming.navigationStart;
console.log('DOMContentLoaded Time: ' + domContentLoadedTime + ' ms');
});
首次内容绘制时间(First Contentful Paint, FCP) :指浏览器渲染出第一个文本、图片、非空白 canvas
或 SVG
的时间。
if (
PerformanceObserver &&
PerformanceObserver.supportedEntryTypes &&
PerformanceObserver.supportedEntryTypes.includes("paint")
) {
const observer = new PerformanceObserver((list) => {
list.getEntriesByName("first-contentful-paint").forEach((entry) => {
console.log("First Contentful Paint: " + entry.startTime + " ms");
});
});
observer.observe({ type: "paint", buffered: true });
}
首次绘制时间(First Paint, FP) :计算浏览器开始渲染任何内容的时间。
if (
PerformanceObserver &&
PerformanceObserver.supportedEntryTypes &&
PerformanceObserver.supportedEntryTypes.includes("paint")
) {
const observer = new PerformanceObserver((list) => {
list.getEntriesByName("first-paint").forEach((entry) => {
console.log("First Paint: " + entry.startTime + " ms");
});
});
observer.observe({ type: "paint", buffered: true });
}
最大内容绘制时间(Largest Contentful Paint, LCP) :计算视口内最大内容元素完成渲染的时间。
if (
PerformanceObserver &&
PerformanceObserver.supportedEntryTypes &&
PerformanceObserver.supportedEntryTypes.includes(
"largest-contentful-paint"
)
) {
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
console.log("Largest Contentful Paint: " + entry.startTime + " ms");
});
});
observer.observe({ type: "largest-contentful-paint", buffered: true });
}
首次输入延迟(First Input Delay, FID) :计算用户首次与页面交互到浏览器响应的时间。
if (
PerformanceObserver &&
PerformanceObserver.supportedEntryTypes &&
PerformanceObserver.supportedEntryTypes.includes("first-input")
) {
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
console.log(
"First Input Delay: " +
(entry.processingStart - entry.startTime) +
" ms"
);
});
});
observer.observe({ type: "first-input", buffered: true });
}
可互动时间(Time to Interactive, TTI) :指页面完全加载,并能够快速响应用户输入的时间。通常情况下,TTI
可以定义为主线程空闲时间达到一定阈值(例如 5
秒)并且页面已经完成渲染并可以响应用户交互的时间点。
这里手动计算比较麻烦,所以使用了Google官方的计算TTI的库 —— tti-polyfill
// 伪代码
ttiPolyfill.getFirstConsistentlyInteractive(opts).then((tti) => {
longtaskObserver.disconnect();
});
累积布局偏移(Cumulative Layout Shift, CLS) :主要用于衡量页面在加载过程中的视觉稳定性。它反映了页面内容在加载过程中是否会发生不期而至的布局变化,这些变化可能会导致用户误操作或者视觉上的不适。
减少布局偏移可以提升页面的视觉稳定性和用户满意度,从而降低用户流失率,提高页面的转化率和留存率。
let totalCls = 0;
const clsObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
// 遍历所有 layout-shift 类型的 PerformanceEntry
for (const entry of entries) {
if (entry.entryType === "layout-shift") {
// 计算每个 layout-shift 事件的 CLS 分数
const clsScore = entry.value; // CLS 分数
console.log("Layout Shift Score:", clsScore);
totalCls += clsScore;
// 在这里进行累加,计算累计布局偏移 CLS
// 累计布局偏移 CLS = 累计 layout-shift 事件的 CLS 分数之和
}
}
});
// 开始监听 layout-shift 类型的 PerformanceEntry
clsObserver.observe({ type: "layout-shift", buffered: true });
window.addEventListener("load", function () {
// 整个页面(包括资源)已经加载完成,在这里停止计算 CLS 或进行其他操作
console.log("页面加载完成");
clsObserver.disconnect();
});
总阻塞时间(Total Blocking Time, TBT) :从 FCP
到页面完全互动(Time to Interactive, TTI)之间的所有长任务(长于50毫秒)的总时间。
let longtaskObserver;
let TBT = 0;
if (
PerformanceObserver &&
PerformanceObserver.supportedEntryTypes &&
PerformanceObserver.supportedEntryTypes.includes("paint")
) {
const observer = new PerformanceObserver((list) => {
list.getEntriesByName("first-contentful-paint").forEach((entry) => {
longtaskObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach((entry) => {
if (entry.duration > 50) {
// 长任务持续时间超过 50 毫秒
console.log("Long task detected:", entry);
// 在这里累加长任务的持续时间,用于计算 TBT
TBT += entry.duration;
}
});
});
longtaskObserver.observe({ entryTypes: ["longtask"] });
});
});
observer.observe({ type: "paint", buffered: true });
}
// 伪代码
ttiPolyfill.getFirstConsistentlyInteractive(opts).then((tti) => {
longtaskObserver.disconnect();
});
Dom节点数量
DOM
节点的数量直接影响浏览器的内存消耗。过多的DOM
节点会占用大量内存,导致页面性能下降。- 较大的
DOM
树会增加浏览器的渲染和重绘时间。减少DOM
节点的数量可以提高页面的渲染性能,从而提升用户体验。
这里使用 MutationObserver
来获取整棵 dom
树的数量,这里后续上报采用定时上报的方式。
window.addEventListener("load", () => {
// 目标节点
const targetNode = document.body; // 你可以替换成你希望监听的任何节点
console.log("targetNode", targetNode);
// 配置选项
const config = {
childList: true, // 监听目标节点的子节点的变化
subtree: true, // 监听整个子树的变化
};
// 回调函数,当 DOM 发生变化时会被调用
const callback = function (mutationsList, observer) {
let nodeCount = 0;
for (let mutation of mutationsList) {
if (mutation.type === "childList") {
// 统计添加和删除的节点数量
nodeCount +=
mutation.addedNodes.length - mutation.removedNodes.length;
}
}
if (nodeCount > 0) {
console.log("DOM节点数量变化:", nodeCount);
console.log(
"当前DOM节点数量:",
document.getElementsByTagName("*").length
);
}
};
// 创建一个 MutationObserver 实例并传入回调函数
const domObserver = new MutationObserver(callback);
// 开始监听
domObserver.observe(targetNode, config);
});
FPS
帧率(Frames Per Second, FPS)是评估页面动画和用户交互流畅度的重要指标。可以使用 requestAnimationFrame
来实现一个帧率监控。它会在浏览器准备好绘制下一帧时调用指定的回调函数。通过计算多个帧之间的时间差,可以得到帧率。
这里可以分成两个维度去上报,第一个维度是直接上报 FPS
,最终可以统计一个平均帧率,中位数等;第二个可以上报一个低帧率,比如 FPS
小于 30
。这样等我们做了一些性能优化之后,可以看平均帧率有无提高,低帧率总数有无降低。
let lastTime = performance.now();
let frameCount = 0;
let fps = 0;
function monitorFPS() {
const currentTime = performance.now();
frameCount++;
const deltaTime = currentTime - lastTime;
if (deltaTime >= 1000) { // 每秒计算一次 FPS
fps = (frameCount / deltaTime) * 1000;
console.log(`当前 FPS: ${fps.toFixed(2)}`);
frameCount = 0;
lastTime = currentTime;
}
requestAnimationFrame(monitorFPS);
}
// 启动 FPS 监控
monitorFPS();
内存监控
使用 PerformaceAPI
来获取当前内存的使用情况,可以使用定时的方式来进行数据上报
if (performance.memory) {
console.log('JS Heap Size Limit:', performance.memory.jsHeapSizeLimit);
console.log('Total JS Heap Size:', performance.memory.totalJSHeapSize);
console.log('Used JS Heap Size:', performance.memory.usedJSHeapSize);
}
网络监控
navigator.connection
API 提供了有关设备的网络连接信息,可以监听网络连接的变化。
if ("connection" in navigator) {
const connection =
navigator.connection ||
navigator.mozConnection ||
navigator.webkitConnection;
function updateConnectionStatus() {
console.log("Effective network type: " + connection.effectiveType);
console.log("Downlink: " + connection.downlink + "Mbps");
console.log("RTT: " + connection.rtt + "ms");
console.log(
"Save Data Mode: " + (connection.saveData ? "on" : "off")
);
}
connection.addEventListener("change", updateConnectionStatus);
updateConnectionStatus();
}
SDK开发
介绍完上面的一些指标和采集的思路之后,我们开始来开发 SDK
,打包工具选择的是 rollup
。首先 npm init
创建一个项目。
然后安装一些必要的库
npm install rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-babel @rollup/plugin-json rollup-plugin-terser rollup-plugin-serve rollup-plugin-livereload --save-dev
看一下整个项目结构:
根目录下创建一个 rollup.config.js
文件,填入如下配置:
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import { babel } from "@rollup/plugin-babel";
import { terser } from "rollup-plugin-terser";
import json from "@rollup/plugin-json";
import serve from "rollup-plugin-serve";
import livereload from "rollup-plugin-livereload";
const isDev = process.env.NODE_ENV === "development";
export default {
input: "src/index.js",
output: {
file: "dist/monitoring-sdk.js",
format: "umd",
name: "MonitoringSDK",
},
plugins: [
resolve(),
commonjs(),
babel({ babelHelpers: "bundled" }),
terser(),
json(),
...(isDev
? [
serve({
open: true,
contentBase: ["public", "dist"],
port: 3000,
}),
livereload({
watch: "dist",
}),
]
: []),
],
};
解释下上面的配置:
input
:打包入口ouput
:- file: 指定输出文件的路径和名称,这里是
dist/monitoring-sdk.js
。 - format: 指定输出模块的格式,这里是
umd
(通用模块定义),可以兼容多种模块系统(CommonJS、AMD、全局变量)。 - name: 指定当以
<script>
标签引入时的全局变量名称,这里是MonitoringSDK
,即window.MonitoringSDK
。
- file: 指定输出文件的路径和名称,这里是
- 插件:
resolve
:打包npm模块commonjs
:将commonjs规范转换为ES6babel
:ES6+转换为ES5或者更兼容的版本terser
:打包压缩优化等serve
:启动一个本地服务器,开发调试使用livereload
:开发过程中的热重载
在public目录下新建一个index.html,填入如下内容:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>监控SDK调试</title>
<script src="../monitoring-sdk.js"></script>
</head>
<body>
<h1>监控SDK调试</h1>
<script>
console.log("MonitoringSDK", window.MonitoringSDK);
</script>
</body>
</html>
然后加入如下两个命令
开发调试时使用 npm run start
,我们随便写点东西:
可以看到开发服务器已经启动起来
然后我们就可以在 Performance.js
中编写性能监控相关的代码
以 FCP
为例,
采集到数据之后需要进行上报,我们先定义好一个 request
函数,后续再实现上报的逻辑。真正的埋点系统会有更多的参数预留,我们这里仅作讲解,就只上报了两个参数, type
跟 value
。
自定义埋点
同时需要对外暴露一个自定义埋点的函数,新建一个 Custom.js
,编写如下逻辑:
import { MONITOR_TYPE } from "./constant";
import { request } from "./request";
class Custom {
log = (value) => {
request(MONITOR_TYPE.CUSTOM, value);
};
}
export { Custom };
再改一下入口文件:
数据上报
现在我们就开始处理数据上报的逻辑,接触过数据上报的同学可能会比较清楚,对于数据上报。我们一般情况下会有三种方式:
sendBeacon
优点:
- 使用简单,适合发送较小的数据量。
- 不会阻塞页面卸载或影响页面性能。
缺点:
- 有大小限制(大约 64KB),不适合发送大数据。
- 不支持所有旧版浏览器。
navigator.sendBeacon('/log', JSON.stringify({type: 'FCP',value:"1"}));
GIF
优点:
- 几乎所有浏览器都支持这种方式,包括一些非常老的浏览器。
- 实现简单,通过构造一个
Image
对象来发送数据。
缺点:
- URL 有长度限制,不适合发送大数据。
const img = new Image();
img.src = '/log.gif?type=FCP&value=1';
AJAX
优点:
- 可以发送和接收大量数据,适合复杂的数据交互。
- 可以处理服务器响应,确认数据是否成功接收。
缺点:
- 在此场景下需要处理跨域问题
我们最后就采用gif的方式来作为埋点上报的方式,在具体请求函数中写入如下代码:
const BASE_URL = "http://localhost:8080/monitor/log.gif?params=";
export const request = (type, value) => {
let params = { type, value };
params = JSON.stringify(params);
params = encodeURIComponent(params);
const url = `${BASE_URL}${params}`;
const img = new Image();
img.src = url;
};
OK,至此,我们已经把请求发出去。接下来我们就需要起一个后端服务来处理请求逻辑了。
我这边选的是Nest,没有接触过的同学可以看一下我的Nest专栏或者其他Nest相关的资料。
import { Controller, Get, Query, Res } from '@nestjs/common';
import { LogService } from './log.service';
import { Response } from 'express';
@Controller('monitor')
export class LogController {
constructor(private readonly logService: LogService) {}
private readonly base64Gif =
'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
@Get('log.gif')
log(@Query('query') query: any, @Res() res: Response) {
this.logService.handleLog(query);
res.setHeader('Content-Type', 'image/gif');
res.send(Buffer.from(this.base64Gif, 'base64'));
}
}
访问 http://localhost:8080/monitor/log.gif?q=q
即可以上报数据。
数据存储
Loki、Grafana 和 Promtail 是一个现代日志处理和监控平台的组成部分。它们结合在一起,提供了日志收集、存储和可视化的功能。
1. Loki
简介
Loki 是由 Grafana Labs 开发的开源日志聚合系统。它被设计为轻量级、分布式、可扩展的日志存储解决方案。与传统的日志系统不同,Loki 专注于高效地存储、索引和检索日志数据。
主要功能
- 日志收集:接收来自应用程序、服务和系统的日志数据。
- 索引和存储:以低成本的方式存储日志数据,并为日志数据提供基本的索引功能。
- 日志查询:支持灵活的查询语言,可以方便地从日志数据中提取有用的信息。
- 集成 Grafana:无缝集成 Grafana 来进行日志可视化和分析。
2. Grafana
简介
Grafana 是一个开源的数据可视化和监控平台。它可以从各种数据源中获取数据,并提供图形化的仪表板来展示数据。Grafana 是日志数据可视化的核心工具。
主要功能
- 数据可视化:支持各种图表、表格和面板来展示数据。
- 仪表板创建:用户可以创建和定制仪表板,展示监控数据和日志信息。
- 数据源管理:支持多种数据源,包括Prometheus、Loki、Elasticsearch等。
- 报警系统:可以设置警报规则,并在触发时发送通知。
Promtail
简介
Promtail 是一个日志收集代理,用于将日志数据从文件系统中提取出来并发送到 Loki。它是Loki生态系统中的一个重要组件,负责从各种来源抓取日志。
主要功能
- 日志收集:从文件系统、系统日志和其他日志来源中提取日志数据。
- 日志处理:支持对日志进行标签处理、过滤和转换。
- 数据推送:将处理后的日志数据发送到Loki进行存储和索引。
首先确保你的环境里已经安装好docker跟docker-compose,然后我们新建一个docker-compose.yml文件如下:
version: '3'
services:
loki:
image: grafana/loki:2.2.0
ports:
- "3100:3100"
command: -config.file=/etc/loki/local-config.yaml
volumes:
- ./loki/config:/etc/loki
- ./loki/data:/loki
user: "1000:1000"
promtail:
image: grafana/promtail:2.2.0
volumes:
- /var/log:/var/log
- ./promtail/config:/etc/promtail
command: -config.file=/etc/promtail/promtail-config.yaml
grafana:
image: grafana/grafana:7.3.6
environment:
- GF_SECURITY_ADMIN_USER=myadminuser
- GF_SECURITY_ADMIN_PASSWORD=myadminpassword
ports:
- "3000:3000"
再新建两个logstash的配置文件如下:
- loki/config/local-config.yaml
auth_enabled: false
server:
http_listen_port: 3100
grpc_listen_port: 9095
ingester:
lifecycler:
address: 127.0.0.1
ring:
kvstore:
store: inmemory
replication_factor: 1
final_sleep: 0s
chunk_idle_period: 5m
chunk_retain_period: 30s
max_transfer_retries: 0
schema_config:
configs:
- from: 2020-10-24
store: boltdb-shipper
object_store: filesystem
schema: v11
index:
prefix: index_
period: 24h
storage_config:
boltdb_shipper:
active_index_directory: /loki/index
cache_location: /loki/boltdb-cache
cache_ttl: 24h
shared_store: filesystem
filesystem:
directory: /loki/chunks
compactor:
working_directory: /loki/compactor
shared_store: filesystem
compaction_interval: 1h
limits_config:
enforce_metric_name: false
reject_old_samples: true
reject_old_samples_max_age: 168h
chunk_store_config:
max_look_back_period: 0s
table_manager:
retention_deletes_enabled: false
retention_period: 0s
- promtail/config/promtail-config.yaml
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: system
static_configs:
- targets:
- localhost
labels:
job: varlogs
__path__: /var/log/*log
启动成功后打开http://localhost:3000 就可以进到Grafana的可视化界面:
登录时的账号密码就是docker-compose.yml的配置:
- GF_SECURITY_ADMIN_USER=myadminuser
- GF_SECURITY_ADMIN_PASSWORD=myadminpassword
其实一般收集数据来说,都是服务端先把日志写在本地或者容器的一个log文件里,然后通过日志采集工具,这里是promtail,去上报到日志引擎,这里是loki。
我这里为了方便,就直接使用loki的api来推送数据了:
然后打开grafana看所推送的数据:
同样的,也可以通过api来查询loki中的数据:
最后,再完整的实现一下nest到loki的接口:
写接口:
async handleLog(query: any) {
let decodeQuery = decodeURIComponent(query);
const data: { type: string; value: any } = JSON.parse(decodeQuery);
const { type, value } = data;
const params = {
streams: [
{
stream: {
type,
},
values: [[`${Date.now() * 1000000}`, value + '']],
},
],
};
const res = await axios.post(
'http://localhost:3100/loki/api/v1/push',
params,
{
headers: {
'Content-Type': 'application/json',
},
},
);
}
读接口:
async getLog(query: string, start: string, end: string) {
const res = await axios.get(
`http://localhost:3100/loki/api/v1/query_range?query=${query}&start=${start}&end=${end}`,
);
return res.data.data.result;
}
数据可视化
拿到数据之后,做可视化其实也就比较简单了,横轴是时间,纵轴是具体的指标值,具体你也可以拓展一些99线,95线等值。
这里具体需要注意的是,纳秒值需要转换一下:
const date = new Date(parseInt(item[0], 10) / 1000000);
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`;
具体的实现可以使用echarts或者其他库去实现,相对来说不是太难的点,这里就不做展开。
最后
埋点也好,日志平台也好,要做完善不是一件简单的事情。希望这篇文章能帮助你对日志平台有一个较为全面的认识,细节之处未能尽全敬请谅解。
如果有哪些地方写的不对,也恳请指教。
最后,如果觉得对你有帮助的话,点点关注点点赞吧~
转载自:https://juejin.cn/post/7394790673613029388