前端错误日志收集
1、采集内容
- 用户信息:当前状态、权限 等
- 行为信息:界面路径、执行的操作 等
- 异常信息:错误栈、错误描述、配置的信息 等
- 环境信息:设备型号、标识、操作系统 等
字段 | 类型 | 描述 |
---|---|---|
time | String | 错误发生的时间 |
userId | String | 用户id |
userName | String | 用户名 |
userRoles | Array | 用户权限列表 |
errorStack | String | 错误栈 |
errorTimeStamp | Number | 时间戳 |
UA | String | 浏览器 userAgent |
config | Object | 请求接口失败时,配置信息 |
request | Object | 请求接口失败时,响应信息/请求信息 |
… | … | … |
2、异常捕获
2.1 全局捕获 window.onerror
重写 onerror、或者监听 onerror 事件,全局收集错误信息
window.onerror = function (errorType, errorFilename, errorLineNo, errorColNo, error) { // errorType => 错误类型 // errorFilename => 错误文件名 // errorLineNo => 错误行 // errorColNo => 错误列 // error => 错误信息,error.message 错误描述,error.stack 错误栈 const errorInfo = { errorMessage: `[Window warn]: ${error.message}${error.stack}`, errorStack: error.stack, ... } }
2.2 全局捕获 window.unhandledrejection
通过监听 unhandledrejection 发出的错误,收集 Promise 抛出的异常错误信息;
window.addEventListener('unhandledrejection', error => { const saveError = sysInfo.getData({ errorMessage: `[Window warn]: ${error.reason.message}${error.reason.stack}`, errorStack: error.reason.stack, ... }) })
2.3 Vue.config.errorHandler 捕获错误
Vue debug.js 解析错误栈
利用 Vue 提供 debug 能力,收集错误信息;通过 Vue 源码中提供的 debug.js 找到错误栈
// https://github.com/vuejs/vue/blob/master/src/core/util/debug.js const classifyRE = /(?:^|[-_])(\w)/g const classify = (str) => str.replace(classifyRE, c => c.toUpperCase()).replace(/[-_]/g, '') const ROOT_COMPONENT_NAME = '<Root>' const ANONYMOUS_COMPONENT_NAME = '<Anonymous>' const repeat = (str, n) => { let res = '' while (n) { if (n % 2 === 1) res += str if (n > 1) str += str // eslint-disable-line no-param-reassign n >>= 1 // eslint-disable-line no-bitwise, no-param-reassign } return res } export const formatComponentName = (vm, includeFile) => { if (!vm) return ANONYMOUS_COMPONENT_NAME if (vm.$root === vm) return ROOT_COMPONENT_NAME const options = vm.$options let name = options.name || options._componentTag const file = options.__file if (!name && file) { const match = file.match(/([^/\\]+)\.vue$/) if (match) name = match[1] } return (name ? `<${classify(name)}>` : ANONYMOUS_COMPONENT_NAME) + (file && includeFile !== false ? ` at ${file}` : ``) } export const generateComponentTrace = (vm) => { if (vm?._isVue && vm?.$parent) { const tree = [] let currentRecursiveSequence = 0 while (vm) { if (tree.length > 0) { const last = tree[tree.length - 1] if (last.constructor === vm.constructor) { currentRecursiveSequence += 1 vm = vm.$parent // eslint-disable-line no-param-reassign continue } else if (currentRecursiveSequence > 0) { tree[tree.length - 1] = [last, currentRecursiveSequence] currentRecursiveSequence = 0 } } tree.push(vm) vm = vm.$parent // eslint-disable-line no-param-reassign } const formattedTree = tree .map((vm, i) => `${(i === 0 ? '---> ' : repeat(' ', 5 + i * 2)) + (Array.isArray(vm) ? `${formatComponentName(vm[0])}... (${vm[1]} recursive calls)` : formatComponentName(vm))}`) .join('\n') return `\n\nfound in\n\n${formattedTree}` } return `\n\n(found in ${formatComponentName(vm)})` }
Vue.config.errorHandler 收集错误信息
options 配置信息
{ attachProps: '是否获取属性', logErrors: '是否提示错误', ... }
const { errorHandler, silent } = Vue.config Vue.config.errorHandler = (error, vm, lifecycleHook) => { // 解析错误栈 const trace = vm ? generateComponentTrace(vm) : '' const message = `Error in ${lifecycleHook}: "${error && error.toString()}"` // 是否已经存在有一个 errorHandler,如果存在 则混入错误信息 if (typeof errorHandler === 'function') { errorHandler.call(Vue, error, vm, lifecycleHook) } // 在控制台输出 错误日志 if (options.logErrors) { const hasConsole = typeof console !== 'undefined' if (Vue.config.warnHandler) { Vue.config.warnHandler.call(null, message, vm, trace) } else if (hasConsole && !silent) { // eslint-disable-next-line no-console console.error(`[Vue warn]: ${message}${trace}`) } } const errorInfo = { errorMessage: `[Vue warn]: ${message}${trace}`, errorStack: trace, ... } }
2.4 收集 request
- 在 axios 拦截器中,收集并解析错误信息
const interceptorReject = err => { requestError(error) } axios.interceptors.request.use(e => {}, interceptorReject) axios.interceptors.response.use(e => {}, interceptorReject)
- 解析错误日志,获取 请求的配置信息、请求头、响应信息等
requestError (error) { try { const { config: { data, headers: ConfigHeaders, maxContentLength, method, timeout, url, xsrfCookieName, xsrfHeaderName }, status, headers, request: { readyState, response, responseText, responseType, responseURL, responseXML, status: requestStatus, statusText: requestStatusText, withCredentials }, statusText } = { ...error.response } const errorInfo = { requests: { config: { data, headers: ConfigHeaders, maxContentLength, method, timeout, url, xsrfCookieName, xsrfHeaderName }, status, headers: { ...headers }, request: { readyState, response, responseText, responseType, responseURL, responseXML, status: requestStatus, statusText: requestStatusText, withCredentials }, statusText }, errorMessage: `[Request warn]: ${error.message}${error.stack}`, errorStack: error.stack, ... }) } catch (err) { ... } }
3、日志存储
选择 localStorage OR IndexedDB ?
- localStorage 存储空间 大概在 2 ~ 10M,可以长期保留,API 使用简单,但存储类型有一定的限制,且 读写都是同步进行的,容易阻塞线程
- IndexedDB 是一个事务型数据库系统;存储空间一般没有限制,可以长期保留,用于在客户端存储大量的结构化数据,API 同时提供了索引实现对数据的高性能搜索,读写异步进行,不易阻塞线程,API 是强大的,但对于简单的情况可能看起来太复杂;
程序错误信息会不间断的收集、为了方便统计和存储大量的错误信息,IndexedDB 显得更加的适应需求;
IndexedDB API 相对比较复杂,因此需要对其进行封装,使其使用起来更加的便捷;
封装 IndexedDB
- 根据 IndexedDB API 进行封装
收集到的日志可以 添加到浏览器 IndexedDB 数据库中;
const db = new Database() ... db.add({ // 可以将收集到的错误信息(errorInfo),通过此方法添加到浏览器数据库中 userId: '', time: '', ...errorInfo, ... })
/* 200 => 获取成功 * 404 => 未找到数据 * 500 => 操作失败 * 1004 => 浏览器不支持 IndexedDB */ const _log = function (...e) { // console.log('%c[[IDB]]: ', 'color: #00f;line-height: 24px;', ...e) } const isIDB = 'indexedDB' in window class Database { // 初始化 constructor (db = 'JSErrorDB', sn = 'JSErrorStore', v = 1) { this.IDB = window.indexedDB || window.webkitIndexedDB || window.mozIndexedDB || window.msIndexedDB this.db = null this.store = null this.IDBName = db this.storeName = sn this.version = v if (isIDB) this.init() else console.log('%c 初始化 IndexedDB 失败,浏览器暂不支持!') } init () { const openRequest = this.IDB.open(this.IDBName, this.version) // 版本更新时 重新连接仓库 openRequest.onupgradeneeded = e => { this.db = e.target.result this.createdStore() } // 连接数据库成功后,连接仓库 openRequest.onsuccess = e => { this.db = e.target.result this.createdStore() } // 连接数据库失败,可以尝试重新连接 this.init() openRequest.onerror = e => { ... } } // 创建仓库 createdStore () { if (!this.db.objectStoreNames.contains(this.storeName)) { this.db.createObjectStore(this.storeName, { autoIncrement: true }) } } // 创建事务 transaction () { const t = this.db.transaction([this.storeName], 'readwrite') t.onabort = e => { _log('事务中断', e) } t.oncomplete = e => { _log('事务已完成', e) } t.onerror = e => { _log('事务出错', e) } const store = t.objectStore(this.storeName) return store } // 添加数据 add (v) { return new Promise((resolve, reject) => { if (!isIDB) return reject({ code: 1004, message: '浏览器暂不支持 IndexedDB!' }) _log('执行函数', this.db) const store = this.transaction() const request = store.add(v) request.onsuccess = e => { _log('添加数据成功', e.target.result, v) resolve({ code: 200, data: { key: e.target.result, data: v }, message: '添加成功' }) } request.onerror = e => { _log('添加数据失败', e) reject(e) } }) } // 根据 Key 获取详情 get (n) { return new Promise((resolve, reject) => { if (!isIDB) return reject({ code: 1004, message: '浏览器暂不支持 IndexedDB!' }) const store = this.transaction() const ob = store.get(n) ob.onsuccess = e => { _log(`读取[[${n}]]成功`, e) resolve({ code: e.target.result ? 200 : 404, data: e.target.result, message: e.target.result ? '获取成功' : '未找到对应 key' }) } ob.onerror = e => { _log(`读取[[${n}]]失败`, e) reject(e) } }) } // 获取列表 getList (obj = {}) { const { page = 1, size = 50 } = obj let advanced = false return new Promise((resolve, reject) => { if (!isIDB) return reject({ code: 1004, message: '浏览器暂不支持 IndexedDB!' }) const store = this.transaction() const request = store.openCursor() const data = [] request.onsuccess = e => { const cursor = e.target.result _log('获取所有信息', e, data.length < (page * size), page, size) if (cursor && cursor !== null && data.length < size) { if (!advanced && page !== 1) { advanced = true cursor.advance((page - 1) * size) return } data.push({ key: cursor.key, data: cursor.value }) cursor.continue() } else { _log('遍历结束', e, data) resolve({ code: 200, data, message: '获取成功' }) } } request.onerror = e => { _log('遍历错误:', e) reject(e) } }) } // 更新信息 put (obj = {}) { const { key, data } = obj return new Promise((resolve, reject) => { if (!isIDB) return reject({ code: 1004, message: '浏览器暂不支持 IndexedDB!' }) const store = this.transaction() const request = store.put(data, key) request.onsuccess = e => { _log('更新数据成功', data, e) resolve({ code: 200, data: { key: key, data: data }, message: '更新成功' }) } request.onerror = e => { _log('更新数据失败', e) reject(e) } }) } // 删除指定信息 delete (k) { return new Promise((resolve, reject) => { if (!isIDB) return reject({ code: 1004, message: '浏览器暂不支持 IndexedDB!' }) const store = this.transaction() const request = store.delete(k) request.onsuccess = e => { _log('删除数据成功', k, e) resolve({ code: 200, data: e.target.result, message: '删除成功' }) } request.onerror = e => { _log('删除数据失败', e) reject(e) } }) } // 默认清理一个月之前的日志 expireClear (time = 60 * 60 * 24 * 30 * 1000) { return new Promise((resolve, reject) => { if (!isIDB) return reject({ code: 1004, message: '浏览器暂不支持 IndexedDB!' }) const store = this.transaction() const request = store.openCursor() const data = [] const currentTime = new Date().getTime() request.onsuccess = e => { const cursor = e.target.result if (cursor && cursor !== null && currentTime - time > cursor.value.errorTimeStamp) { data.push({ key: cursor.key, data: cursor.value }) cursor.delete() cursor.continue() } else { resolve({ code: 200, data, message: '清理结束' }) } } request.onerror = e => { _log('遍历错误:', e) reject(e) } }) } // 清理所有日志 clear () { return new Promise((resolve, reject) => { if (!isIDB) return reject({ code: 1004, message: '浏览器暂不支持 IndexedDB!' }) const store = this.transaction() const request = store.clear() request.onsuccess = e => { resolve({ code: 200, data: e.target.result, message: '清空数据成功' }) } request.onerror = e => { _log('清空数据失败', e) reject(e) } }) } // 关闭连接 close () { this.db.close() } } export { Database }
日志查看
- 通过连接浏览器仓库查看日志记录
<template> <div class='log'> <div class='flex btn'> <button @click='$router.go(-1)'> 返回 </button> <input type="text" v-model='page' placeholder='页'> <input type="text" v-model='size' placeholder='条'> <button @click='btn("getList")'> 获取第{{page}}页数据 </button> <button @click='btn("clear")'> 删除全部 </button> <button @click='btn("getErrorInfoList")'> 获取错误日志 </button> </div> <div> <div class='log-item' v-for='(v, i) in errorInfoList' :key=i> <div class='flex-left'> <div>{{v.data.userName}}</div> <div>{{v.data.errorTime}}</div> <div>{{v.data.errorType}}</div> </div> <div class='flex-right'> <div class='log-request' v-if='v.data.errorType === "Request"'> {{v.data.requests}} </div> <div>{{v.data.errorMessage || v.data.errorStack}}</div> </div> </div> </div> </div> </template> <script> import { Database } from '@/utils/JSError/indexedDB' const a = 13 export default { name: 'ErrorLog', data () { return { errorInfoList: [], page: 1, size: 20 } }, async created () { this.db = new Database() }, methods: { btn (type) { const { page, size } = this if (type === 'getList') this.db.getList({ page: page, size: size }).then(res => { if (res.code === 200) { res.data.forEach(v => { console.error(v.data.errorTime, '\n', v.data.errorMessage) }) this.errorInfoList = res.data } }) if (type === 'clear') this.db.clear().then(res => { console.log('清空文本成功', res) }) if (type === 'getErrorInfoList') this.getErrorInfoList() }, getErrorInfoList () { this.db.getList().then(res => { console.log('获取错误日志', res) if (res.code === 200) { res.data.forEach(v => { console.error(v.data.errorTime, '\n', v.data.errorMessage) }) this.errorInfoList = res.data } }) } } } </script>