手撸一个前端日志上报组件
话说在前头
随着前端发展,前端越来越承接更多的职责,业务的复杂度也越来越高。即使开发和测试同学上线之前都做了充分的测试,但到了线上真实的环境代还是会遇到一些未知问题,这些问题通常是很多随机因素叠加形成的,因此很难定位问题的原因,经常会遇到明明本地是好的,但是线上却是有问题的”尴尬“。
这时我们急需一个这样的工具:可以记录用户操作以及线上环境情况(网络、性能、访问统计、稳定性)的日志,外加可以按照设定的策略自动把这些日志上传到服务器中用于问题分析
这样前端上线后不再像“脱缰的野马”,出现问题也可“有迹可循”
技术难点解析
了解我们的痛点之后,下一步就该想如何解决,需要采用什么样的技术了
异常捕获
- 全局捕获
error
和unhandledrejection
事件
window.addEventListener("error", (e) => {
console.log("系统发生错误:", e);
});
window.addEventListener("unhandledrejection", (e) => {
console.log("未处理的Promsie错误", e.reason);
});
- try……catch……
捕获代码异常
try {
// code
} catch (e) {}
上报
- 利用构建一个 Image 的 DOM 对象来完成日志上报
new Image().src = `/log?page=${locaction.href}&err=${error}……`;
优点:传输效率高,摆脱跨域限制
缺点:只能是 GET 请求,受 URL 字符限制
- 利用 Ajax 上报日志
const xhr = XMLHttpRequest
? new XMLHttpRequest()
: new ActiveXObject("Msxml2.XMLHTTP"); // 兼容低版本浏览器
xhr.open("POST", "https://log.xxx.com", true); // true 表示异步
xhr.setRequestHeader("Content-Type", "appliction/json"); // open后才可以设置header
xhr.send(JSON.stringify(errorObj)); // 发送请求
优点:
- 相比 GET 方式来说更安全
- 可传输大容量的日志内容
缺点:
- 如果采用单独日志域名的话,受跨域的限制
- 传输效率不如 GET 方式
sendBeacon
如果有日志记录就马上上报话,就马上上报的话是非常不划算的。一来消耗了系统资源,二来增加服务器压力。所以需要把页面中的日志进行合并然后在“恰当”的时机上传到服务器。
这个“恰当”的时机通常是页面“unload”或者“beforeunload”时候来做,但是这样又有新的问题。利用 Ajax 上传的话默认是异步的方式,也就说有可能页面已经销毁了日志数据还没上传完,页面销毁了 Ajax 对象也就释放了从而造成上传中断失败。
实际开发当中会把 Ajax 改成同步的方式,保证日志上传完成后页面再销毁。但还会一起新的问题,由于是同步请求,会造成页面性能问题影响用户体验。
navigator.sendBeacon
就是 W3C 解决上面问题所提出的解决方案,使用 sendBeacon()
方法会在有机会时异步地向服务器发送数据,同时不会延迟页面的卸载或影响下一导航的载入性能。这就解决了上面说的所有问题,并且数据可靠,传输异步并且不会影响下一页面的加载。此外,代码实际上还要比其他技术简单许多!
window.addEventListener(
"unload",
() => {
navigator.sendBeacon("/log", analyticsData);
},
false
);
性能监控
Performance API
Performance 接口可以获取到当前页面中与性能相关的信息
上图是 Performance 对应浏览器页面加载的整个过程
Performance.timing 包含性能有关信息
- navigationStart: 表示从上一个文档卸载结束时的 unix 时间戳,如果没有上一个文档,这个值将和 fetchStart 相等。
- unloadEventStart: 表示前一个网页(与当前页面同域)unload 的时间戳,如果无前一个网页 unload 或者前一个网页与当前页面不同域,则值为 0。
- unloadEventEnd: 返回前一个页面 unload 时间绑定的回掉函数执行完毕的时间戳。
-
- redirectStart: 第一个 HTTP 重定向发生时的时间。有跳转且是同域名内的重定向才算,否则值为 0。
redirectEnd: 最后一个 HTTP 重定向完成时的时间。有跳转且是同域名内部的重定向才算,否则值为 0。
- redirectStart: 第一个 HTTP 重定向发生时的时间。有跳转且是同域名内的重定向才算,否则值为 0。
- fetchStart: 浏览器准备好使用 HTTP 请求抓取文档的时间,这发生在检查本地缓存之前。
- domainLookupStart/domainLookupEnd: DNS 域名查询开始/结束的时间,如果使用了本地缓存(即无 DNS 查询)或持久连接,则与 fetchStart 值相等
- connectStart: HTTP(TCP)开始/重新 建立连接的时间,如果是持久连接,则与 fetchStart 值相等。
- connectEnd: HTTP(TCP) 完成建立连接的时间(完成握手),如果是持久连接,则与 fetchStart 值相等。
- secureConnectionStart: HTTPS 连接开始的时间,如果不是安全连接,则值为 0。
- requestStart: HTTP 请求读取真实文档开始的时间(完成建立连接),包括从本地读取缓存。
- responseStart: HTTP 开始接收响应的时间(获取到第一个字节),包括从本地读取缓存。
- responseEnd: HTTP 响应全部接收完成的时间(获取到最后一个字节),包括从本地读取缓存。
- domLoading: 开始解析渲染 DOM 树的时间,此时 Document.readyState 变为 loading,并将抛出 readystatechange 相关事件。
- domInteractive: 完成解析 DOM 树的时间,Document.readyState 变为 interactive,并将抛出 readystatechange 相关事件,注意只是 DOM 树解析完成,这时候并没有开始加载网页内的资源。
- domContentLoadedEventStart: DOM 解析完成后,网页内资源加载开始的时间,在 DOMContentLoaded 事件抛出前发生。
- domContentLoadedEventEnd: DOM 解析完成后,网页内资源加载完成的时间(如 JS 脚本加载执行完毕)。
- domComplete: DOM 树解析完成,且资源也准备就绪的时间,Document.readyState 变为 complete,并将抛出 readystatechange 相关事件。
- loadEventStart: load 事件发送给文档,也即 load 回调函数开始执行的时间。
- loadEventEnd: load 事件的回调函数执行完毕的时间。
Performance.navigation
表示当前网页导航类型及次数
- redirectCount: 0 // 页面经过了多少次重定向
- type: 0
- 0 表示正常进入页面;
- 1 表示通过 window.location.reload() 刷新页面;
- 2 表示通过浏览器前进后退进入页面;
- 255 表示其它方式
Performance.memory
其是 Chrome 添加的一个非标准扩展,这个属性提供了一个可以获取到基本内存使用情况的对象。
- jsHeapSizeLimit: 内存大小限制
- totalJSHeapSize: 可使用的内存
- usedJSHeapSize: JS 对象占用的内存
- 白屏时间
白屏时间是指浏览器从响应用户输入域名地址回车到浏览器开始显示内容的时间
白屏时间 = firstPaint - performance.timing.navigationStart || pageStartTime
<!DOCTYPE html>
<html>
<head>
<title>白屏时间<title>
<script>
window.pageStartTime = Date.now() // 兼容不支持performance接口的浏览器
</script>
<link rel="stylesheet" href="xxx.css">
<script>
const endTime = Date.now()
console.log('白屏时间:',endTime - performance?performace.timing.navigationStart:pageStartTime)
</script>
<head>
<body>
</body>
</html>
- 首屏加载时间
首屏时间是指浏览器从响应用户输入域名地址到首屏内容渲染完成的时间,关于首屏内容渲染完成的时间一般是以最终首屏图片的加载时间
<!DOCTYPE html>
<html>
<head>
<title>首屏加载时间<title>
<script>
window.pageStartTime = Date.now() // 兼容不支持performance接口的浏览器
</script>
<head>
<body>
</body>
<script>
const endTime = Date.now()
console.log('首屏加载时间:',endTime - performance?performace.timing.navigationStart:pageStartTime)
</script>
</html>
存储
对于简单的应用,在用户关闭页面之前上传日志是最好的。但对于复杂的大型应用来说,有时需要回放用户细节的操作,会产生大量的并且有依赖关系的日志,这时就需要把这些日志保存在一个地方,然后根据设定上传策略统一上传。这时如何存储是我们要思考的问题。
- localStorage
浏览器数据存储技术,可以将数据持久化在本地,跟Cookie
相比没有过期时间,存储容量也要大一些,一般支持存储 2.5MB 到 10MB 之间内容,只支持字符串的数据春初
// setItem 保存数据
localStorage.setItem("key", "value");
// getItem 获取数据
localStorage.getItem("key");
// removeItem 删除数据
localStorage.removeItem("key");
// removeItem 清除所有数据
localStorage.clear();
// 遍历
const storage = window.localStorage;
for (let i = 0, len = storage.length; i < len; i++) {
const key = storage.key(i);
const value = storage.getItem(key);
console.log(`key:${key},value:${value}`);
}
localStorage
只能解决浏览器存储的基本问题,对于数据的检索和索引都不支持,所以又诞生了 WebSQL
技术 和 IndexDB
技术
- WebSQL Database
WebSQL Database 也是一种浏览器数据存储技术,基于浏览器上实现一个类关系型数据库,可以用 JS 来操作 SQL 完成对数据的操作。li'lun 它不是一个标准的 H5 规范,W3C 组织在 2010 年 11 月 18 日废弃了 WebSQL 技术。
const size = 2 * 1024 * 1024;
const db = openDatabase("数据库名称", "版本号", "描述文本", size);
db.transaction(function (tx) {
tx.executeSql("CREATE TABLE IF NOT EXISTS LOGS (id unique, log)");
tx.executeSql('INSERT INTO LOGS (id, log) VALUES (1, "菜鸟教程")');
tx.executeSql('INSERT INTO LOGS (id, log) VALUES (2, "www.runoob.com")');
console.log("数据表已创建,且插入了两条数据。");
});
db.transaction(function (tx) {
tx.executeSql("DELETE FROM LOGS WHERE id=1");
console.log("删除 id 为 1 的记录。");
});
db.transaction(function (tx) {
tx.executeSql("UPDATE LOGS SET log='www.w3cschool.cc' WHERE id=2");
console.log("更新 id 为 2 的记录。");
});
db.transaction(function (tx) {
tx.executeSql(
"SELECT * FROM LOGS",
[],
function (tx, results) {
const len = results.rows.length;
console.log(`查询记录条数: ${len}`);
for (let i = 0; i < len; i++) {
console.log(`==${results.rows.item(i).log}==`);
}
},
null
);
});
- IndexedDB
IndexDB 和 WebSQL 类似都是浏览器存储解决方案,不同的是 IndexDB 更像是 NoSQL 数据库,而 WebSQL 更像是关系型数据库
IndexedDB 具有以下特点:
- 键值对储存。 IndexedDB 内部采用对象仓库(object store)存放数据。所有类型的数据都可以直接存入,包括 JavaScript 对象。对象仓库中,数据以"键值对"的形式保存,每一个数据记录都有对应的主键,主键是独一无二的,不能有重复,否则会抛出一个错误。
- 异步。 IndexedDB 操作时不会锁死浏览器,用户依然可以进行其他操作,这与 LocalStorage 形成对比,后者的操作是同步的。异步设计是为了防止大量数据的读写,拖慢网页的表现。
- 支持事务。 IndexedDB 支持事务(transaction),这意味着一系列操作步骤之中,只要有一步失败,整个事务就都取消,数据库回滚到事务发生之前的状态,不存在只改写一部分数据的情况。
- 同源限制 IndexedDB 受到同源限制,每一个数据库对应创建它的域名。网页只能访问自身域名下的数据库,而不能访问跨域的数据库。
- 储存空间大 IndexedDB 的储存空间比 LocalStorage 大得多,一般来说不少于 250MB,甚至没有上限。
- 支持二进制储存。 IndexedDB 不仅可以储存字符串,还可以储存二进制数据(ArrayBuffer 对象和 Blob 对象)。
基本概念
-
数据库:IDBDatabase 对象,数据库是一系列相关数据的容器。每个域名(严格的说,是协议 + 域名 + 端口)都可以新建任意多个数据库。
IndexedDB 数据库有版本的概念。同一个时刻,只能有一个版本的数据库存在。如果要修改数据库结构(新增或删除表、索引或者主键),只能通过升级数据库版本完成。 -
对象仓库:IDBObjectStore 对象,每个数据库包含若干个对象仓库(object store)。它类似于关系型数据库的表格。
-
索引: IDBIndex 对象,对象仓库保存的是数据记录。每条记录类似于关系型数据库的行,但是只有主键和数据体两部分。主键用来建立默认的索引,必须是不同的,否则会报错。主键可以是数据记录里面的一个属性,也可以指定为一个递增的整数编号。数据体可以是任意数据类型,不限于对象
-
事务: IDBTransaction 对象,为了加速数据的检索,可以在对象仓库里面,为不同的属性建立索引。
数据记录的读写和删改,都要通过事务完成。事务对象提供 error、abort 和 complete 三个事件,用来监听操作结果。 -
操作请求:IDBRequest 对象
-
指针: IDBCursor 对象
-
主键集合:IDBKeyRange 对象
// 打开数据库
let db = null;
const request = window.indexedDB.open("MyTestDatabase");
request.onerror = function (event) {
// 错误处理
console.log(" 打开数据库报错");
};
request.onsuccess = function (event) {
// 成功处理
db = event.target.result;
console.log("打开数据库成功");
};
// 创建和更新数据库版本号
request.onupgradeneeded = function (event) {
db = event.target.result;
var objectStore = null;
if (!db.objectStoreNames.contains("school")) {
objectStore = db.createObjectStore("school", { keyPath: "id" });
// unique name可能会重复
objectStore.createIndex("name", "name", { unique: false });
objectStore.add({ id: 1, name: "北京交通大学" });
}
};
更进一步思考
有了上面的技术就可以写出一个基本日志上报的组件了,但离实际的应用还有些差距,需要额外的考虑一些因素
可靠性
-
异常隔离
在经过严格侧是的情况下,日志上报组件出现异常的可能性非常小,但还是会有异常情况发生。如果发生这个异常不应该影响业务系统运行,最佳的方式是使用try...catch...
捕获,再以业务的维度与业务系统对于日志组件发生的异常的应在业务级别单独出来上报,建议使用new Image().src
的方式上传 -
资源隔离
日志上报不应占用业务计算资源,应该单独占用一份后台资源。同时要有一个专用的域名(主要是浏览器对同一个域名有并发数限制)。
使用专用域名的话会面临跨域问题,可以在服务器实现CORS(Cross-origin resource sharing)来解决
性能
-
压缩日志内容长度
把相同的部分(比如出错的文件地址)提取出来合到一起上传 -
省去响应体
对于日志上报来说,客户端并不需要考虑后端是否有响应,所以我们可以使用HEAD
方式上报,并且后端返回的响应体为空,避免响应体造成的资源浪费 -
使用Http2
在HTTP 1.x中,每次http请求中都会传输一系列的请求头来描述请求的资源及特性,然而每次请求都有很多相同的值,如Host
、User-agent
等,这些数据能够占到300~800byte的传输量,如果携带较大的cookie,请求头甚至可以占用1kb的空间。无形中造成了资源的浪费。这个问题再HTTP 2中得到很好的解决, HTTP 2的头部压缩采用Hpack技术,每次请求都会吧请求头压缩的至原大小一半,如果后面重复发送请求,那么可能压缩后的头部只有原大小的1/10 -
使用Webworker
上传日志的操作可以放到 webroker
中操作,具体实现在下一节可以看到
手撸一个(vue 版)
下面基于开源组件logline 撸一个日志上传组件,来上源码:
// 首先要`npm install -S logline`
import Logline from 'logline'
if (!Logline) {
throw new Error('请安装Logline组件')
}
const Klog = {}
const defaultOptions = {
name: 'klog',
protocol: 'indexeddb',
keep: 7,
report: {
xhr: {
url: window.location.host,
method: 'POST',
timeout: 5000,
headers: {},
withCredentials: false
},
strategy: {
name: 'size', // size(大小)、time(时间)
value: 1000
},
period: 60 * 30 // 单位秒
}
}
const PROTOCOL = {
'websql': Logline.PROTOCOL.WEBSQL,
'indexeddb': Logline.PROTOCOL.INDEXEDDB,
'localstorage': Logline.PROTOCOL.LOCALSTORAGE
}
Klog.install = function(Vue, options) {
const opts = deepObjectMerge(defaultOptions, options)
Logline.using(PROTOCOL[opts.protocol])
Logline.keep(opts.keep)
const log = new Logline(opts.name)
window.Klog = Klog
Vue.prototype.$info = Klog.$info = function(key, data) {
log.info(key, JSON.stringify(data || ''))
}
Vue.prototype.$warn = Klog.$warn = function(key, data) {
log.warn(key, JSON.stringify(data || ''))
}
Vue.prototype.$error = Klog.$error = function(key, data) {
log.error(key, JSON.stringify(data || ''))
}
Vue.prototype.$log = Klog.$log = function(...args) {
log.critical(JSON.stringify(...args))
}
Vue.prototype.$clean = Klog.$clean = function() {
log.clean()
}
window.addEventListener('error', (e) => {
const msg = e.message.toLowerCase()
if (msg.indexOf('script error') > -1) { // 资源加载错误
log.error('[Klog source error]', JSON.stringify(msg || ''))
} else {
log.error('[Klog application error]', JSON.stringify({
'Message: ': e.message,
'URL: ': e.filename,
'Line: ': e.lineno,
'Column: ': e.colno,
'Error object: ': JSON.stringify(e.error)
}))
}
})
window.addEventListener('unhandledrejection', e => {
log.error('[Klog application unhandledrejection]', JSON.stringify(e || ''))
})
const nativeAjaxSend = window.XMLHttpRequest.prototype.send
const nativeAjaxOpen = window.XMLHttpRequest.prototype.open
window.XMLHttpRequest.prototype.open = function(mothod, url, ...args) { // 劫持open方法,是为了拿到请求的url
const xhrInstance = this
xhrInstance._url = url
return nativeAjaxOpen.apply(this, [mothod, url].concat(args))
}
window.XMLHttpRequest.prototype.send = function(...args) {
const xhrInstance = this
xhrInstance.addEventListener('error', function(e) {
log.error(JSON.stringify({
'Message: ': '[Klog ajax error]',
'Stack: ': JSON.stringify({
status: e.target.status,
statusText: e.target.statusText
}),
'Url: ': xhrInstance._url
}))
})
return nativeAjaxSend.apply(this, args)
}
// check周期(时间、大小)
const timer = setInterval(() => {
const strategy = opts.report.strategy
try {
Logline.all(function(logs) {
if (!logs || logs.length === 0) {
return
}
const lst = parseInt(localStorage.getItem('last_sync_time'), 10) || 0
const latest = logs.filter(log => log.time > lst)
if (latest.length > 0) {
let ls = latest
if (strategy.name === 'size') {
ls = latest.slice(-strategy.value)
} else if (strategy.name === 'time') {
const before = new Date().getTime() - strategy.value * 60 * 1000 // 单位分钟
ls = latest.filter(l => l.time > before)
}
if (ls.length > 0) {
logWorker.postMessage({ target: 'report', content: JSON.stringify({ data: ls }) })
}
}
})
} catch (e) {
log.error('[Klog interval error]', e.message || e)
}
}, opts.report.period * 1000)
function createWorker(content, data) {
if (typeof Worker !== 'undefined') {
try {
data.name = 'Klog'
const blob = new Blob(['(' + content.toString() + ')(' + JSON.stringify(data) + ')'])
const objUrl = window.webkitURL ? window.webkitURL : window.URL
return new Worker(objUrl.createObjectURL(blob), { name: 'Klog' })
} catch (err) {
log.error('[Klog error]', JSON.stringify(err || ''))
}
}
log.info('[Klog error]', '当前浏览器不支持webworker')
return {}
}
// webworker用于上传
function handleReport(config) {
const xhrOpts = config.xhr
const ajaxUtils = function(data, s, f) {
const xhr = new XMLHttpRequest()
xhr.withCredentials = xhrOpts.withCredentials
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
return s(xhr.responseText)
} else {
return f(xhr.status)
}
}
}
xhr.open(xhrOpts.method, xhrOpts.url, true)
for (const key in xhrOpts.headers) { // 必须放到open方法后面
xhr.setRequestHeader(key, xhrOpts.headers[key])
}
xhr.timeout = xhrOpts.timeout
// const formData = new FormData()
// formData.append('data', data)
xhr.send(data)
return xhr
}
self.name = config.name
onmessage = function(e) {
const data = e.data || e
if (data.target === 'report') {
const content = data.content
ajaxUtils(content, data => {
console.log('ok', data)
self.postMessage({ target: 'reportcomplete', content: new Date().getTime() })
}, err => {
console.log('fail:', err)
})
}
}
onmessageerror = function(e) {
console.log('[Klog message error]', e.message || e)
}
onerror = function(e) {
console.log('[Klog error]', e.message || e)
}
}
function deepObjectMerge(a, b) { // 深度合并对象
for (var key in b) {
a[key] = a[key] && a[key].toString() === '[object Object]'
? deepObjectMerge(a[key], b[key]) : a[key] = b[key]
}
return a
}
const logWorker = createWorker(handleReport, {
name: opts.name,
xhr: opts.report.xhr
})
logWorker.onmessage = function(e) {
try {
const data = e.data || e
if (data.target === 'reportcomplete') {
localStorage.setItem('last_sync_time', data.content)
}
} catch (err) {
console.error(err)
}
}
logWorker.onmessageerror = function(e) {
console.log('[Klog error]', e)
}
logWorker.onerror = function(e) {
log.error('[Klog error]', JSON.stringify(e || ''))
}
// 更新同步事件
window.addEventListener('beforeunload', () => {
logWorker.terminate()
clearInterval(timer)
return null
}, false)
}
export default Klog
使用
import Klog from '@/plugin/log'
Vue.use(Klog, { report: {
xhr: {
url: `${window.location.origin}/api/wb/frontlog/createlog`,
method: 'POST',
withCredentials: true,
headers: {
Authorization: `Bearer ${getToken()}`,
'Content-type': 'application/json'
}
},
strategy: {
name: 'time', value: 10 // 10分钟之前
}
}})