一文搞懂得物前端監(jiān)控
一背景
二監(jiān)控類型
前端監(jiān)控的范圍很廣,如監(jiān)控性能,監(jiān)控異常,監(jiān)控告警等一系列的維度來確保我們的頁面和功能是正常的,在出現(xiàn)問題時研發(fā)可以及時做出響應,及時追蹤,定位問題。
性能監(jiān)控
圖片
總體來說性能監(jiān)控是做的比較大的,而且監(jiān)控對提高頁面的秒開是有實際優(yōu)化指導意義的,離線包、預請求等都是優(yōu)化手段。
異常告警監(jiān)控
圖片
這些錯誤類的信息不一定是我們都需要關注的,有些疑難雜癥,但是有不影響頁面展示和功能的報錯,也是可以忽略的,要知道不是所有的錯誤都能被解決的,這個時候我們可以只關注那些影響我們頁面核心功能的部分,針對這部分做一個告警配置,例如:
圖片
針對告警信息,再進一步對錯誤進行分析,找到能解決的問題,達到對頁面穩(wěn)定性的把控。
日常巡檢監(jiān)控
圖片
三監(jiān)控知識梳理
前端監(jiān)控的必要性
- 用戶在訪問頁面的時候大致會經(jīng)歷下面的階段:
- 向服務端請求獲取靜態(tài)資源;
- 瀏覽器加載資源;
資源加載成功之后頁面渲染繼續(xù)運行。
這些階段都有報錯的可能,而前端要做的就是監(jiān)控后面這階段:資源加載和頁面交互。
做前端監(jiān)控也有很多其他好處, 例如:
- 第一時間上報異常,解決問題;
- 能夠比較完整的重現(xiàn)問題用戶的操作全流程路徑,方便開發(fā)者復現(xiàn)問題,定位問題;
- 針對頁面的 PV、UV 等信息可以為產(chǎn)品和運營做推廣決策提供數(shù)據(jù)依據(jù)。
前端監(jiān)控是很有必要的,通過監(jiān)控,我們能在線上應用異常時,第一時間收到反饋,并及時止損。對業(yè)務的發(fā)展是有正向作用的。
前端監(jiān)控目標
保證穩(wěn)定性(錯誤監(jiān)控)
錯誤監(jiān)控包括 JavaScript 代碼錯誤、Promsie 錯誤、接口(XHR,F(xiàn)etch)錯誤、資源加載錯誤(Script,Link等)等,這些錯誤大多會導致頁面功能異常甚至白屏。
提升用戶體驗(性能監(jiān)控)
性能監(jiān)控包括頁面的加載時間、接口響應時間等,側面反應了用戶體驗的好壞。
- 加載時間:頁面運行時各個階段的加載時間;
- TTFB(Time To First Byte)(首字節(jié)時間):瀏覽器發(fā)起第一個請求到數(shù)據(jù)返回第一個字節(jié)所消耗的時間,這個時間包含了網(wǎng)絡請求時間、后端處理時間;
- FP(First Paint)(首次繪制):首次繪制包括了任何用戶自定義的背景繪制,它是將第一個像素點繪制到屏幕的時刻;
- FCP(First Content Paint)(首次內(nèi)容繪制):首次內(nèi)容繪制是瀏覽器將第一個 DOM 渲染到屏幕的時間,可以是任何文本、圖像、SVG 等的時間;
- FMP(First Meaningful paint)(首次有意義繪制):首次有意義繪制是頁面可用性的量度標準;
- LCP(Largest Contentful Paint):視窗內(nèi)最大的圖片或者文本渲染的時間,當最大的內(nèi)容塊渲染完的時候,基本上主內(nèi)容都加載完了,與現(xiàn)有的頁面加載指標相比,與用戶體驗的相關性更好;
- FID(First Input Delay)(首次輸入延遲):用戶首次和頁面交互到頁面響應交互的時間;
- 卡頓:指超過 50ms 的長任務,具體的指標可以根據(jù)頁面的內(nèi)容進行調(diào)節(jié),一般 50ms 人眼就能感覺到卡頓。
針對業(yè)務進行統(tǒng)計
- PV:Page View 即頁面瀏覽量或點擊量;
- UV:指訪問某個站點的不同 IP 地址的人數(shù);
- 頁面的停留時間:用戶在每一個頁面的停留時間。
前端監(jiān)控的流程
前端埋點(通過 SDK 給頁面的 DOM 都加上標記)
- 數(shù)據(jù)上報(收集,存儲)
- 分析和計算(將采集到的數(shù)據(jù)進行加工匯總)
- 可視化展示(按照緯度將數(shù)據(jù)展示)
- 監(jiān)控報警(發(fā)現(xiàn)異常后按一定的條件觸發(fā)報警)
前端埋點方案
代碼埋點
代碼埋點,就是項目中引入埋點 SDK,手動在業(yè)務代碼中標記,觸發(fā)埋點事件進行上報。比如頁面中的某一個模塊的點擊事件,會在點擊事件的監(jiān)聽中加入觸發(fā)埋點的代碼 this.$track('事件名', { 需要上傳的業(yè)務數(shù)據(jù) }),將數(shù)據(jù)上報到服務器端。
- 優(yōu)點:能夠在任何時刻,更精確的發(fā)送需要的數(shù)據(jù)信息,上報數(shù)據(jù)更靈活;
- 缺點:工作量大,代碼侵入太強,過于耦合業(yè)務代碼,一次埋點的更改就要引起發(fā)版之類的操作。
這個方案也是我們實際項目中現(xiàn)有的方案。
可視化埋點
通過可視化交互的手段,代替代碼埋點,可以新建、編輯、修改埋點。在組件和頁面的維度進行埋點的設計。
將業(yè)務代碼和埋點代碼分離,提供一個可視化交互的頁面,輸入為業(yè)務代碼,通過這個可視化系統(tǒng),可以在業(yè)務代碼中自定義的增加埋點事件,最后輸出的代碼耦合了業(yè)務代碼和埋點代碼。
這個方案是可以解決第一種代碼埋點的痛點,也是我們目前正準備做的方案。
無痕埋點
前端的任意一個事件都被綁定一個標識,所有的事件都被記錄下來,通過定期上傳記錄文件,配合文件解析,解析出來我們想要的數(shù)據(jù),并生成可視化報告。
無痕埋點的優(yōu)點是采集全量數(shù)據(jù),不會出現(xiàn)漏埋和誤埋等現(xiàn)象。缺點是給數(shù)據(jù)傳輸和服務器增加壓力,也無法靈活定制數(shù)據(jù)結構。針對業(yè)務數(shù)據(jù)的準確性不高。
監(jiān)控腳本
日志存儲
前端的埋點上報需要存儲起來,這個可以使用阿里云的日志服務,不需要投入開發(fā)就可以采集。
新建一個項目比如:xxx-monitor
新建一個存儲日志,根據(jù)阿里云的要求發(fā)起請求,攜帶需要上報的數(shù)據(jù):
http://${project}.${host}/logstores/${logStore}/track
圖片
代碼中調(diào)用 Track 上報日志:
日志的上報可以封裝成公共的調(diào)用方式, monitor/utils/里面放所有的工具方法;
tracker.js 的實現(xiàn)就是按照阿里云的上報格式發(fā)送請求,并帶上處理好的需要上報的業(yè)務數(shù)據(jù)即可,下面的都是固定的,在日志服務建好:
圖片
實現(xiàn)一個 Tracker 類導出類的實例即可,這樣在監(jiān)控的核心代碼中直接調(diào)用 tracker.send(data),核心實現(xiàn)代碼如下:
// monitor/utils/get/track.js
...
class SendTrackLoger {
constructor() {
this.url = `http://${project}.${host}/logstores/${logStore}/track`
this.xhr = new XMLHttpRequest()
}
send(data = {}, callback) {
const logData = {...logData}
for(let key in logs) {
if (typeof logs[key] === 'number') {
logs[key] = `${logs[key]}` // 這是阿里云的要求,字段不能是數(shù)字類型
}
}
let body = JSON.stringify({
__logs__: [logs]
})
this.xhr.open('POST', this.url, true)
this.xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8')
this.xhr.setRequestHeader('x-log-apiversion', '0.6.0')
this.xhr.setRequestHeader('x-log-bodyrawsize', body.length)
this.xhr.onload = function() {
if (this.status >= 200 && this.status <= 300 || this.status === 304) {
callback && callback()
}
}
this.xhr.onerror = function(error) {
console.log(error)
}
this.xhr.send(body)
}
}
export default new SendTrackLoger()
這里展示的是自定義要上報的數(shù)據(jù)字段:
圖片
監(jiān)控錯誤
前端需要監(jiān)控的錯誤有兩類:
- Javascript 錯誤(JS 錯誤,Promise 異常)
- 監(jiān)聽 Error 錯誤(資源加載錯誤)
腳本實現(xiàn)
新建一個 fronend-monitor 項目,這個項目就相當于我們的工程項目,監(jiān)控的核心實現(xiàn)可以寫到項目里面,也可以抽成 SDK 的形式 Import 引入進來,這里先寫到項目中。
webpack.config.js 用來打包項目,做接口數(shù)據(jù) Mock,測試 XHR 請求監(jiān)控接口錯誤等。
const path = require('path')
const HtmlWebpackPlugin = xxx
module.exports = {
mode: 'development',
context: process.cwd(),
entry:'./src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'monitor.js'
},
devServer: {
contentBase: path.resolve(__dirname, 'dist'),
before(router) {
router.get('/success', function(req, res) {
res.json({ id: 1 })
})
router.post('/error', function(req, res) {
res.sendStatus(500)
})
},
},
module: {},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
inject: "head"
})
]
}
新建一個 src/index.html 在這個里面寫一些問題代碼,然后測試監(jiān)控的錯誤捕獲。
// src/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" cnotallow="width=device-width, initial-scale=1.0">
<title>monitor</title>
</head>
<body>
<input id="jsErrorBtn" type="button" value="js 代碼錯誤" notallow="btnClick()" />
<input id="promiseErrorBtn" type="button" value="promise 錯誤" notallow="promiseClick()" />
<input id="successBtn" type="button" value="成功 ajax 請求" notallow="successAjax()" />
<input id="errorBtn" type="button" value="失敗 ajax 請求" notallow="errorAjax()" />
<script>
function btnClick() {
window.goods.type = 2
}
function promiseClick() {
new Promise((resolve, reject) => {
resolve(1)
}, () => {
console.log(123)
})
}
function successAjax() {
var xhr = new XMLHttpRequest()
xhr.open('GET', '/success', true)
xhr.responseType = 'json'
xhr.onload = function () {
console.log(xhr.response)
}
xhr.send()
}
function errorAjax() {
var xhr = new XMLHttpRequest()
xhr.open('POST', '/error', true)
xhr.responseType = 'json'
xhr.onload = function() {
console.log(xhr.response)
}
xhr.onerror = function(err) {
console.log(err)
}
xhr.send('name=123')
}
</script>
</body>
</html>
上報未捕獲的 Javascript 錯誤
Javascript 錯誤分為 2 種:語法錯誤,資源家加載錯誤,這些錯誤都會被 window.addEventListener('error', function(event) {})捕獲,來判斷是否是資源加載錯誤。
window.addEventListener('error', function(event) {
// 如果 target 是script link 等資源
if (event.target && (event.target.src || event.target.href)) {
const element = getElement(event.target || event.path)
tracker.send({
...
title: document.title,
url: location.href,
timestamp: event.timeStamp,
userAgent: navigator.userAgent,
type: 'resourceError',
...
})
} else {
tracker.send({
...
title: document.title,
url: location.href,
timestamp: event.timeStamp,
userAgent: navigator.userAgent,
type: 'jsError',
...
})
}
}, true)
代碼中未被捕獲的 Promise 錯誤,要監(jiān)聽 unhandledrejection 事件 window.addEventListener('unhandledrejection', function(event) {})。
// 監(jiān)聽未捕獲的 promise 錯誤
window.addEventListener('unhandledrejection', function(event) {
// PromiseRejectionEvent
let message = ''
let stack = ''
const reason = event.reason
let filename = ''
let lineno = ''
let colno = ''
if (reason) {
message = reason.message
stack = reason.stack
const match = stack.match(/\s+at\s+(.+):(\d+):(\d+).+/)
filename = match[1]
lineno = match[2]
colno = match[3]
}
tracker.send({
...
title: document.title,
url: location.href,
timestamp: event.timeStamp,
userAgent: navigator.userAgent,
type: 'promiseError',
...
})
}, true)
接口異常上報
接口異常上報主要是攔截請求,攔截 XMLHttpRequest 對象,改寫 XHR 的 Open 和 Send 方法,將需要上報的數(shù)據(jù)發(fā)到阿里云存儲,監(jiān)聽 Load,Error,Abort 事件,上報數(shù)據(jù):
// src/monitor/lib/xhr.js
import tracker from '../utils/tracker'
export default function injectXHR() {
// 獲取 window 上的 XMLHttpRequest 對象
const XMLHttpRequest = window.XMLHttpRequest
// 保存舊的open, send函數(shù)
const prevOpen = XMLHttpRequest.prototype.open
const prevSend = XMLHttpRequest.prototype.send
// 不可使用箭頭函數(shù),不然會找不到 this 實例
XMLHttpRequest.prototype.open = function (method, url, async, username, password) {
// 重寫open,攔截請求
// 不攔截 track 本身以及 socket, 直接放行
if (!url.match(/logstores/) && !url.match(/sockjs/)) {
this.logData = { method, url, async, username, password }
}
return prevOpen.apply(this, arguments)
}
XMLHttpRequest.prototype.send = function (body) {
// 重寫 send,攔截有 logData 的請求,獲取 body 參數(shù)
if (this.logData) {
this.logData.body = body
let startTime = Date.now()
function handler(type) {
return function (event) {
// event: ProgressEvent
let duration = Date.now() - startTime
let status = this.status
let statusText = this.statusText
console.log(event)
tracker.send({
type: 'xhr',
eventType: type,
pathname: this.logData.url,
status: `${status} ${statusText}`,
duration: `${duration}`, // 接口響應時長
response: this.response ? JSON.stringify(this.response) : '',
params: body || '',
})
}
}
this.addEventListener('load', handler('load'), false)
this.addEventListener('error', handler('error'), false)
this.addEventListener('abort', handler('abort'), false)
}
return prevSend.apply(this, arguments)
}
}
監(jiān)控白屏
白屏就是頁面上什么東西也沒有,在頁面加載完成之后,如果頁面上的空白點很多,就說明頁面是白屏的,需要上報,這個上報的時機是:document.readyState === 'complete' 表示文檔和所有的子資源已完成加載,表示load(window.addEventListener('load')狀態(tài)事件即將被觸發(fā)。
document.readyState 有三個值:loading(document正在加載),interactive(可交互,表示正在加載的狀態(tài)結束,但是圖像,樣式和框架之類的子資源仍在加載),complete 就是完成,所以監(jiān)控白屏需要在文檔都加載完成的情況下觸發(fā)。
// src/monitor/utils/onload.js
export function onload(callback) {
if (document.readyState === 'complete') {
callback()
} else {
window.addEventListener('onload', callback)
}
}
監(jiān)控白屏的思路主要是:可以將可視區(qū)域中心點作為坐標軸的中心,在X、Y軸上各分 10 個點,找出這個 20 個坐標點上最上層的 DOM 元素,如過這些元素是包裹元素,空白點數(shù)就加一,包裹元素可以自定義比如 Html Body App Root Container Content 等,空白點數(shù)大于 0 就上報白屏日志。
export default function computedBlankScreen() {
// 包裹玉元素列表
const wrapperSelectors = ['body', 'html', '#root', '#App']
// 空白節(jié)點的個數(shù)
let emptyPoints = 0
// 判斷20個點處的元素是否是包裹元素
function isWrapper(element) {
const selector = getSelector(element)
console.log(selector)
if (wrapperSelectors.indexOf(selector) >= 0) { // 表示是在包裹元素里面,空白點就要加一
emptyPoints++
}
}
onload(function() {
let xElements, yElements
for (let i = 0; i <=9; i++) {
xElements = document.elementFromPoint(window.innerWidth * i / 10, window.innerHeight / 2)
yElements = document.elementFromPoint(window.innerHeight * i / 10, window.innerWidth / 2)
// document.elementFromPoint 返回的是某一個坐標點的由到外的html元素的集合
isWrapper(xElements[0])
isWrapper(yElements[0])
}
if (emptyPoints >= 0) {
let centerPoint = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
tracker.send()
}
})
}
監(jiān)控卡頓
用戶交互的響應時間如果大于某一個時間,用戶就會感覺卡頓??梢远ㄒ粋€時間比如 100 毫秒,就代表響應時間長,會卡頓。
PerformanceObserver 構造函數(shù)使用給定的觀察者 Callback 生成新的PerformanceObserver 對象,當通過 Observe() 方法注冊條目類型(需要監(jiān)控的類型)的性能條目被記錄下來時,會調(diào)用該觀察者回調(diào)。
所以可以 new PerformanceObserver 來監(jiān)控 longTask,監(jiān)控的資源加載如果超過 100 毫秒就表示卡頓,可以瀏覽器空閑(requestIdleCallback)的時候上報數(shù)據(jù)。
....
export default function longTask() {
new PerformanceObserver(function(list) {
list.getEntries().forEach(function(entry) {
if (entry.duration > 100) {
// 瀏覽器空閑的時候上報
requestIdleCallback(() => {
tracker.send({
type: 'longTask',
eventType: lastEvent.type,
startTime: formatTime(entry.startTime),
duration: formatTime(entry.duration),
});
});
}
})
}).observe({ entryTypes: ['longtask']})
}
性能指標
PerformanceObserver.observe 方法用于觀察傳入的參數(shù)中指定的性能條目類型的集合。當記錄一個指定類型的性能條目時,性能監(jiān)測對象的回調(diào)函數(shù)將會被調(diào)用。performance.timing 記錄了從輸入 URL 到頁面加載完成的所有的時間,從這些字段中可以提取對對頁面性能的監(jiān)控,通過分析這些指標來優(yōu)化頁面的體驗,比如統(tǒng)計 FMP、LCP 等,具體可以查看 MDN。
統(tǒng)計pv (頁面的停留時間)
navigator.connection 對象獲取網(wǎng)絡連接的信息:effectiveType(網(wǎng)絡類型),RTT(估算餓往返時間)等,還能通過監(jiān)聽 window.addEventListener('unload')事件計算用戶在頁面的停留時間。
import tracker from '../util/tracker';
export function pv() {
var connection = navigator.connection;
tracker.send({
type: 'pv',
networkType: connection.effectiveType, // 網(wǎng)絡類型
rtt: connection.rtt, // 往返時間
screen: `${window.screen.width}x${window.screen.height}` // 設備分辨率
});
let startTime = Date.now();
window.addEventListener('unload', () => {
let stayTime = Date.now() - startTime; // 頁面停留時間
tracker.send({
type: 'stayTime',
stayTime
});
}, false);
}
四、總結
參考文章:
- https://juejin.cn/post/6939703198739333127
- https://wicg.github.io/largest-contentful-paint/
- https://developer.mozilla.org/zh-CN/docs/Web/API/PerformanceObserver/PerformanceObserver
- https://developer.mozilla.org/zh-CN/docs/Web/API/Long_Tasks_API
- https://developer.mozilla.org/zh-CN/docs/Web/API/PerformanceTiming
- https://developer.mozilla.org/zh-CN/docs/Web/API/Navigator