前言
项目上线之后,用户如果出现错误(代码报错、资源加载失败以及其他情况),基本上没有办法复现,如果用户出了问题但是不反馈或直接不用了,对开发者或公司来说都是损失。
由于我这个项目比较小,只是一个迷你商城,所以不需要收集很复杂的数据,只需要知道有没有资源加载失败、哪行代码报错就可以了,市面上有很多现成的监控平台比如sentry,在这里我选择通过nodejs自己搭一个服务。
概述
我的项目是使用vue2写的,所以本文主要是讲vue相关的部署过程
1、部署后台服务(使用express)
2、收集前端错误(主要是vue)
3、提交信息到后台分析源码位置及记录日志
js异常处理
function test1 () { console.log('test1 start'); console.log(a); console.log('test1 end'); } function test2 () { console.log('test2 start'); console.log('test2 end'); } test1(); test2();
这里可以看到,当js运行报错后,代码就不往下执行了,这是因为js是单线程,具体可以看看事件循环,这里不做解释
接下来看看使用异步的方式执行,可以看到没有影响代码的继续运行
function test1 () { console.log('test1 start'); console.log(a); console.log('test1 end') } function test2 () { console.log('test2 start'); console.log('test2 end') } settimeout(() => { test1(); }, 0) settimeout(() => { test2(); }, 0)
那报错之后我们如何收集错误呢?
try catch
function test1 () { console.log('test1 start'); console.log(a); console.log('test1 end') } try { test1(); } catch (e) { console.log(e); }
使用try catch
将代码包裹起来之后,当运行报错时,会将收集到的错误传到catch
的形参中,打印之后我们可以拿到错误信息和错误的堆栈信息,但是try catch
无法捕获到异步的错误
function test1 () { console.log('test1 start'); console.log(a); console.log('test1 end') } try { settimeout(function() { test1(); }, 100); } catch (e) { console.log(e); }
可以看到try catch
是无法捕获到异步错误的,这时候就要用到window的error事件
监听error事件
window.addeventlistener('error', args => { console.log(args); return true; }, true) function test1 () { console.log('test1 start'); console.log(a); console.log('test1 end') } settimeout(function() { test1(); }, 100);
除了window.addeventlistener
可以监听error
之后,window.onerror
也可以监听error
,但是window.onerror
和window.addeventlistener
相比,无法监听网络异常
window.addeventlistener
<img src="https://www.baidu.com/abcdefg.gif"> <script> window.addeventlistener('error', args => { console.log(args); return true; }, true) // 捕获 </script>
window.onerror
<img src="https://www.baidu.com/abcdefg.gif"> <script> window.onerror = function(...args) { console.log(args); } </script>
由于无法监听到,这里就不放图了
unhandledrejection
到目前为止,promise
已经成为了开发者的标配,加上新特性引入了async await
,解决了回调地狱的问题,但window.onerror
和window.addeventlistener
,对promise
报错都是无法捕获
window.addeventlistener('error', error => { console.log('window', error); }) new promise((resolve, reject) => { console.log(a); }).catch(error => { console.log('catch', error); })
可以看到,监听window上的error事件是没有用的,可以每一个promise写一个catch,如果觉得麻烦,那么就要使用一个新的事件,unhandledrejection
window.addeventlistener('unhandledrejection', error => { console.log('window', error); }) new promise((resolve, reject) => { console.log(a); })
其中,reason
中存放着错误相关信息,reason.message
是错误信息,reason.stack
是错误堆栈信息
promise错误也可以使用 try catch捕获到,这里就不做演示了
至此,js中同步、异步、资源加载、promise、async/await都有相对应的捕获方式
window.addeventlistener('unhandledrejection', error => { console.log('window', error); throw error.reason; }) window.addeventlistener('error', error => { console.log(error); return true; }, true)
vue异常处理
由于我的项目使用vue2搭建的,所以还需要处理一下vue的报错
export default { name: 'app', mounted() { console.log(aaa); } }
现在的项目基本上都是工程化的,通过工程化工具打包出来的代码长这样,上面的代码打包后运行
通过报错提示的js文件,查看后都是压缩混淆之后的js代码,这时候就需要打包时生成的source map
文件了,这个文件中保存着打包后代码和源码对应的位置,我们只需要拿到报错的堆栈信息,通过转换,就能通过source map
找到对应我们源码的文件及出错的代码行列信息
那我们怎么才能监听error事件呢?
使用vue的全局错误处理函数vue.config.errorhandler
在src/main.js
中写入以下代码
vue.config.errorhandler = (err, vm, info) => { console.log('error: ', err); console.log('vm', vm); console.log('info: ', info); }
现在打包vue项目
打包vue之后然后通过端口访问index.html
,不建议你双击打开,如果你没改过打包相关的东西,双击打开是不行的,可以通过vs code装插件live server
,然后将打包文件夹通过vs code打开
上报错误数据
经过上述的异常处理后,我们需要将收集到的错误进行整理,将需要的信息发送到后台,我这里选择使用ajax发请求到后端,当然你也可以使用创建一个图片标签,将需要发送的数据拼接到src上
这里我选择使用tracekit
库来解析错误的堆栈信息,axios
发请求,dayjs
格式化时间
npm i tracekit npm i axios npm i dayjs
安装完成后在src/main.js
中引入tracekit、axios、dayjs
上报vue错误
import tracekit from 'tracekit'; import axios from 'axios'; import dayjs from 'dayjs'; const protcol = window.location.protocol; let errormonitorurl = `${protcol}//127.0.0.1:9999`; const errormonitorvueinterface = 'reportvueerror'; // vue错误上报接口 tracekit.report.subscribe((error) => { const { message, stack } = error || {}; const obj = { message, stack: { column: stack[0].column, line: stack[0].line, func: stack[0].func, url: stack[0].url } }; axios({ method: 'post', url: `${errormonitorurl}/${errormonitorvueinterface}`, data: { error: obj, data: { errtime: dayjs().format('yyyy-mm-dd hh:mm:ss'), ismobile: /iphone|ipad|ipod|android/i.test(navigator.useragent), // 是否移动端 iswechat: /micromessenger/i.test(navigator.useragent), // 是否微信浏览器 isios: /ipad|iphone|ipod/.test(navigator.useragent) && !window.msstream, // 两个都是false就是未知设备 isandroid: /android/.test(navigator.useragent) && !/windows phone/.test(navigator.useragent) }, browserinfo: { useragent: navigator.useragent, protcol: protcol } } }).then(() => { console.log('错误上报成功'); }).catch(() => { console.log('错误上报失败'); }); }); vue.config.errorhandler = (err, vm, info) => { tracekit.report(err); }
如果你还需要其他的数据就自己加
打包vue之后然后通过端口访问index.html
,不建议你双击打开,如果你没改过打包相关的东西,双击打开是不行的,可以通过vs code装插件live server
,然后将打包文件夹通过vs code打开
现在去项目中看看发出去的请求参数是什么
可以看到我们需要的数据都已经收集到了,上报失败是肯定的,因为我们还没有写好接口
上报window错误
接下来在监听window的error事件,也向后台发送一个错误上报请求
const errormonitorwindowinterface = 'reportwindowerror'; // window错误上报接口 window.addeventlistener('error', args => { const err = args.target.src || args.target.href; const obj = { message: '加载异常' + err }; if (!err) { return true; } axios({ method: 'post', url: `${errormonitorurl}/${errormonitorwindowinterface}`, data: { error: obj, data: { errtime: dayjs().format('yyyy-mm-dd hh:mm:ss'), ismobile: /iphone|ipad|ipod|android/i.test(navigator.useragent), // 是否移动端 iswechat: /micromessenger/i.test(navigator.useragent), // 是否微信浏览器 isios: /ipad|iphone|ipod/.test(navigator.useragent) && !window.msstream, // 两个都是false就是未知设备 isandroid: /android/.test(navigator.useragent) && !/windows phone/.test(navigator.useragent) }, browserinfo: { useragent: navigator.useragent, protcol: protcol } } }).then(() => { console.log('错误上报成功'); }).catch(() => { console.log('错误上报失败'); }); return true; }, true);
搭建监控后台
创建一个文件夹,名字随便,然后在终端中打开文件夹,初始化npm
npm init -y
初始化完成后创建一个server.js,这里我使用express进行搭建后端,source-map用于解析js.map文件,这些库后面会用到
npm i express npm i nodemon npm i source-map
下好包之后在server.js
中输入以下代码,然后在终端输入nodemon server.js
const express = require('express'); const path = require('path'); const fs = require('fs'); const port = 9999; const app = express(); app.use(express.urlencoded({ extended: true })); app.use(express.json()); app.get('/', (req, res) => { res.send('hello world!').status(200); }) app.listen(port, () => { console.log(`服务启动成功,端口号为:${port}`) })
服务启动之后,访问本地的9999端口,查看是否生效,当看到屏幕上显示hello world!表示我们的后端服务成功跑起来了,接下来就是写错误的上传接口
在这里我将为vue和window监控分别写一个接口(因为我懒得一个接口做判断区分,如果你觉得两个接口太麻烦,那你也可以自己优化成一个接口)
编写vue错误上报接口
在server.js
中继续添加
const sourcemap = require('source-map'); app.post('/reportvueerror',async (req, res) => { const urlparams = req.body; console.log(`收到vue错误报告`); console.log('urlparams', urlparams); const stack = urlparams.error.stack; // 获取文件名 const filename = path.basename(stack.url); // 查找map文件 const filepath = path.join(__dirname, 'uploads', filename + '.map'); const readfile = function (filepath) { return new promise((resolve, reject) => { fs.readfile(filepath, { encoding: 'utf-8'}, (err, data) => { if (err) { console.log('readfileerr', err) return reject(err); } resolve(json.parse(data)); }) }) } async function searchsource({ filepath, line, column }) { const rawsourcemap = await readfile(filepath); const consumer = await new sourcemap.sourcemapconsumer(rawsourcemap); const res = consumer.originalpositionfor({ line, column }) consumer.destroy(); return res; } let sourcemapparseresult = ''; try { // 解析sourcemap结果 sourcemapparseresult = await searchsource({ filepath, line: stack.line, column: stack.column }); } catch (err) { sourcemapparseresult = err; } console.log('解析结果', sourcemapparseresult) res.send({ data: '错误上报成功', status: 200, }).status(200); })
然后nodemon
会自动重启服务,如果你不是用nodemon启动的,那自己手动重启一下
打包vue之后然后通过端口访问index.html,不建议你双击打开,如果你没改过打包相关的东西,双击打开是不行的,可以通过vs code装插件live server,然后将打包文件夹通过vs code打开,通过live server
运行,此时应该会报跨域问题
设置允许跨域
可以自己手动设置响应头实现跨域,我这里选择使用cors库
npm i cors
const cors = require('cors'); app.use(cors()); // 这条需要放在 const app = express(); 后
此时重新运行后台,再观察
此时发现,解析map
文件报错了,那是因为我们还没有上传map
文件
在server.js
同级目录下创建一个uploads
文件夹
回到打包vue打包文件目录dist,将js文件夹中所有js.map
结尾的文件剪切到创建的文件夹中,如果你打包文件中没有js.map
,那是因为你没有打开生成js.map
的开关,打开vue.config.js
,在defineconfig
中设置属性productionsourcemap
为true
,然后重新打包就可以了
module.exports = defineconfig({ productionsourcemap: true, // 设置为true,然后重新打包 transpiledependencies: true, lintonsave: false, configurewebpack: { devserver: { client: false } } })
为什么是剪切?如果真正的项目上线时,你把js.map文件上传了,别人拿到之后是可以知道你的源码的,所以必须剪切,或者复制之后回到dist目录删掉所有js.map
这时候我们再刷新网页,然后看后台的输出,显示src/app.vue
的第10行有错
编写window错误上传接口
// 处理window报错 app.post('/reportwindowerror',async (req, res) => { const urlparams = req.body; console.log(`收到window错误报告`); console.log('urlparams', urlparams); res.send({ data: '错误上报成功', status: 200, }).status(200); })
此时我们去vue项目中添加一个img标签,获取一张不存在的图片即可出发错误,由于不用解析,所以这里就不再上传js.map
了
写入日志
错误上报之后我们需要记录下来,接下来我们改造一下接口,收到报错之后写一下日志
我需要知道哪一天的日志报错了,所有我在node项目中也下载dayjs用来格式化时间.
npm i dayjs
此处的日志记录内容只是我自己需要的格式,如果你需要其他格式请自己另外添加
vue错误写入日志
// let sourcemapparseresult = ''; // try { // // 解析sourcemap结果 // sourcemapparseresult = await searchsource({ filepath, line: stack.line, column: //stack.column }); //} catch (err) { // sourcemapparseresult = err; //} //console.log('解析结果', sourcemapparseresult) // 直接将下面的内容粘贴在上面的log下面 const today = dayjs().format('yyyy-mm-dd') // 今天 const logdirpath = path.join(__dirname, 'log'); const logfilepath = path.resolve(__dirname, 'log/' + `log-${today}.txt`) if (!fs.existssync(logdirpath)) { console.log(`创建log文件夹`) fs.mkdirsync(logdirpath, { recursive: true }); } if (!fs.existssync(logfilepath)) { console.log(`创建${today}日志文件`) fs.writefilesync(logfilepath, '', 'utf8'); } const writestream = fs.createwritestream(logfilepath, { flags: 'a' }); writestream.on('open', () => { // writestream.write('uuid:' + urlparams.data.uuid + '\n'); writestream.write('错误类型:window' + '\n'); writestream.write('错误发生时间:' + urlparams.data.errtime + '\n'); writestream.write('ip:' + req.ip + '\n'); writestream.write(`安卓: ${urlparams.data.isandroid} ios: ${urlparams.data.isios} 移动端: ${urlparams.data.ismobile} 微信: ${urlparams.data.iswechat} (安卓和ios同时为false表示未知设备)` + '\n'); writestream.write('用户代理:' + urlparams.browserinfo.useragent + '\n'); writestream.write('错误信息:' + urlparams.error.message + '\n'); writestream.write('---------------------------------- \n'); writestream.end(() => { console.log('vue错误日志写入成功'); console.log('---------------------'); res.send({ data: '错误上报成功', status: 200, }).status(200); }); }) writestream.on('error', err => { res.send({ data: '错误上报失败', status: 404, }).status(404); console.error('发生错误:', err); })
window错误写入日志
和vue写入的方式差不多,存在优化空间
const today = dayjs().format('yyyy-mm-dd') // 今天 const logdirpath = path.join(__dirname, 'log'); const logfilepath = path.join(__dirname, 'log' + `/log-${today}.txt`) if (!fs.existssync(logdirpath)) { console.log(`创建log文件夹`) fs.mkdirsync(logdirpath, { recursive: true }); } if (!fs.existssync(logfilepath)) { console.log(`创建${today}日志文件`) fs.writefilesync(logfilepath, '', 'utf8'); } const writestream = fs.createwritestream(logfilepath, { flags: 'a' }); writestream.on('open', () => { writestream.write('错误类型:window' + '\n'); writestream.write('错误发生时间:' + urlparams.data.errtime + '\n'); writestream.write('ip:' + req.ip + '\n'); writestream.write(`安卓: ${urlparams.data.isandroid} ios: ${urlparams.data.isios} 移动端: ${urlparams.data.ismobile} 微信: ${urlparams.data.iswechat} (安卓和ios同时为false表示未知设备)` + '\n'); writestream.write('用户代理:' + urlparams.browserinfo.useragent + '\n'); writestream.write('错误信息:' + urlparams.error.message + '\n'); writestream.write('---------------------------------- \n'); writestream.end(() => { console.log('window错误日志写入成功'); console.log('---------------------'); res.send({ data: '错误上报成功', status: 200, }).status(200); }); }) writestream.on('error', err => { res.send({ data: '错误上报失败', status: 404, }).status(404); console.error('发生错误:', err); })
至此,收集错误,上报错误,写入日志已经全部完成。
其他
错误监控持久化运行在服务器
这个可以使用pm2
,在服务器上使用node全局安装pm2
库
pm2 ls #显示所有pm2启动的应用 pm2 start /xxx/xxx # 启动/xxx/xxx应用 pm2 save # 保存当前应用列表 pm2 stop id # id 通过pm2 ls查看 pm2 logs id # 查看日志
自动上传js.map文件
如果每次打包后都手动复制js.map
文件的到uploads
文件夹下,似乎有些麻烦
虽然麻烦,但是我自己还是没有自动上传,原因是如果打包就自动上传,那么如果项目还未发布,但是文件已经替换掉之前的文件了,新版本未发布之前,vue的错误就无法解析了,当然,如果你每次上传都不删除以前的文件也是可以的
修改vue项目
在vue项目src
下创建一个plugin
目录,新建一个uploadsourcemap.js
,将下面的代码粘贴进去
const glob = require('glob') const path = require('path') const http = require('http') const fs = require('fs') class uploadsourcemap { constructor (options) { this.options = options } apply (compiler) { console.log('uploadsourcemap') // 在打包完成后运行 compiler.hooks.done.tap('uploadsourcemap', async stats => { const list = glob.sync(path.join(stats.compilation.outputoptions.path, '**/*.js.map')) for (const item of list) { const filename = path.basename(item); console.log(`开始上传${filename}`) await this.upload(this.options.url, item) console.log(`上传${filename}完成`) } }) } upload (url, file) { return new promise((resolve, reject) => { const req = http.request( `${url}/upload?name=${path.basename(file)}`, { method: 'post', headers: { 'content-type': 'application/octet-stream', connection: 'keep-alive', 'transfer-encoding': 'chunked' } } ) fs.createreadstream(file) .on('data', chunk => { req.write(chunk) }) .on('end', () => { req.end() // 删除文件 fs.unlink(file, (err) => { if (err) { console.error(err) } }) resolve() }) }) } } module.exports = uploadsourcemap
修改vue.config.js
主要是引入uploadsourcemap
,并且在configurewebpack => plugins
下使用
const { defineconfig } = require('@vue/cli-service') const uploadsourcemap = require('./src/plugin/uploadsourcemap') module.exports = defineconfig({ productionsourcemap: true, transpiledependencies: true, lintonsave: false, configurewebpack: { plugins: [ new uploadsourcemap({ url: 'http://127.0.0.1:9999' // 后面换成自己的服务器地址 }) ] } })
修改后台
修改server.js
,新增一个上传文件的接口
app.post('/upload', (req, res) => { const filename = req.query.name const filepath = path.join(__dirname, 'uploads', filename) if (!fs.existssync(path.dirname(filepath))) { fs.mkdirsync(path.dirname(filepath), { recursive: true }) } const writestream = fs.createwritestream(filepath) req.on('data', (chunk) => { writestream.write(chunk) }) req.on('end', () => { writestream.end(() => { res.status(200).send(`file ${filename} has been saved.`) }) }) writestream.on('error', (err) => { fs.unlink(filepath, () => { console.error(`error writing file ${filename}: ${err}`) // res.status(500).send(`error writing file ${filename}.`) }) }) })
然后现在重新打包,观察打包输出
最后
尽量是不要开启跨域,否则谁都能给发请求到后台,如果要开跨域,那需要做好判断,主域名不符合的直接返回404终止这次请求。
市面上的监控有很多,有些甚至能实现录制用户操作生成gif,本文只是实现一个基本的错误监控,如有错误请指出。
源码参考:https://github.com/ytanck/demos/tree/master/error-monitor-demo
发表评论