从0到1搭建前端监控系统


搭建异常监控系统

流程

  1. 收集异常报错
  2. 上报异常信息
  3. 构建时将sourcemap文件上传至服务器
  4. 报错时服务器接收错误并记录到日志中
  5. 根据sourcemap和错误日志内容进行错误分析

收集异常报错

代码异常

JS 执行异常

  • Error: 最基本的错误类型 其他类型的原型
  • RangeError:范围异常 比如栈溢出和索引越界
  • ReferenceError:引用错误 比如引用一个不存在的对象
  • SyntaxError:语法错误
  • TypeError: 类型错误
  • URIError:向全局URI处理函数传递一个不合理的URI时 比如 decodeURI('%')
  • EvalError: 关于eval的异常 不会被 js 抛出

可以使用 try...catch| window.onerror = callback | window.addEventListener('error', callback)进行全局捕获

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
window.onerror = function (msg, url, row, col, error) {
console.table({ msg, url, row, col, error: error.stack })
let errorMsg = {
type: 'javascript',
// msg错误消息,error是错误对象,这里拿的是error.stack(异常信息)
msg: error && error.stack ? error.stack || msg,
// 发生错误的行数
row,
// 列数,也就是第几个字符
col,
// 发生错误的页面地址
url,
// 发生错误的时间
time: Date.now()
}

// 然后可以把这个 errorMsg 存到一个数组里,统一上报
// 也可以直接上报
Axios.post({ 'https://xxxx', errorMsg })

// 如果return true,错误就不会抛到控制台
}

资源加载异常

可以使用 window.addEventListener('error', callback, true)

使用 window.onerror = callback是无法捕获静态资源类异常的 因为资源类错误没有冒泡 只能在捕获阶段捕获 所以可以使用 window.addEventListener('error', callback, true)的方式捕获(第三个参数表示是否在捕获阶段调用事件处理 所以可以用来捕获在捕获阶段才会触发的错误类型)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
addEventListener(
"error",
(e) => {
const target = e.target;
if (target != window) {
//这里收集错误信息
let errorMsg = {
type: target.localName, //错误来源名称
url: target.src || target.href, //错误来源的链接
//....
};
//将这个errorMag存入数组中 然后统一上报
//...
}
},
true
);

Promise 异常

在使用 promise时 如果被reject但是没有被catch处理 就会抛出 promise类异常

promise类型的异常无法被 try...catch捕获 也没法被 window.onerror = callback或者 window.addEventListener('error', callback, true)全局捕获 可以通过 window.onrejectionhandled = callback 或者 window.addEventListener('rejectionhandled', callback)去全局捕获

1
2
3
4
5
6
7
8
9
10
11
12
13
14
window.addEventListener("unhandledrejection", (e) => {
console.log(e)
let errorMsg = {
type: 'promise',
msg: e.reason.stack || e.reason
// .....
}
Axios.post({ 'https://xxxx', errorMsg })

// 如果return true,错误就不会抛到控制台
})
new Promise(() => {
s
})

接口请求异常

  • 通过fetch则通过 fetch(url).then(callback).catch(callback)
  • 通过xhr示例发起 则使用 window.addEventListener('error', callback) | window.onerror = callback的方式捕获
  • 如果是 xhr.send方法出现错误 则使用 xhr.onerror | xhr.addEventListener('error', callback)的方式捕获异常

跨域脚本执行异常

如果是跨域脚本出现问题 可以使用 window.addEventListener('error', callback) | window.onerror 捕获异常

此外我们还需要做以下额外操作才能保证获取到

  • 在发起请求的script标签上添加 crossorign = "annoymous"
  • 响应头中添加 Access-Control-Origin: *

Vue 异常

vue2

errorHandle

为应用内抛出的未捕获错误指定一个全局处理函数(错误一旦被捕获后 就不会被抛到控制台)

1
2
app.config.errorHandler = (err, instance, info) => { //
处理错误,例如:报告给一个服务 }

app.congfig.warnHandler

用于为Vue的运行时警告指定一个自定义函数(只在开发环境有效 生产环境会被自忽略)

1
2
3
app.config.warnHandler = (msg, instance, trace) => {
// `trace` is the component hierarchy trace
};

renderError

默认的渲染函数遇到错误时 提供了一个替代渲染输出的(和热重载一起使用很好用)

1
2
3
4
5
6
7
8
new Vue({
render(h) {
throw new Error("oops");
},
renderError(h, err) {
return h("per", { style: { color: red } }, err.stack);
},
}).$mount("#app");

errorCaptured

在捕获了后代组件传递的错误时调用

  • 默认情况下 所有的错误会被发送到 app.config.errorHandler 这些错误能够在这里进行分析并发送给服务器
  • 如果组件的继承链或者组件链上存在多个 errorCaptured钩子 对于同一个错误 这些狗子会按从底到上的顺序调用 类似于冒泡机制
  • 如果 errorCaptured钩子本身抛出了一个错误 那么这个错误和原来捕获到的错误都将被发送到app.config.errorHandler
  • errorCaptured钩子可以通过返回 false来组织错误继续向上传递
1
2
3
4
5
6
7
8
9
Vue.component('ErrorBoundary',{
data: () => { ... }
errorCaptured(err, vm, info){
// err 错误信息
// vm 触发错误的组件实例
// info 错误捕获位置信息
return false
}
})
vue3

onErrorCaptured

1
2
3
onErrorCaptured(function (err, instance, info) {
console.log("[errorCaptured]", err, instance, info);
});

React 异常

getDerivedStateFromError

在后代组件抛出错误时被钓鱼呢(在渲染阶段调用的 所以不允许出现副作用函数)

1
2
3
4
5
6
7
8
9
10
class ErrorBoundary extends React.Component {
constructor(props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error) {
// 更新 state 使下一次渲染可以显降级 UI
return { hasError: true }
}
}

componentDidCatch

在后代组件抛出错误时被调用 大那是不会捕获事件处理器和异步代码的异常 会在 commit阶段被调用 允许出现副作用

1
2
3
4
5
6
7
8
9
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
}
componentDidCatch(error, info) {
// error 错误信息
// info.componentStack 错误组件位置
}
}

但是errorboundaries并不会捕捉这些错误

  • 事件处理程序
  • 异步代码
  • 服务端的渲染代码
  • error boundaries自己抛出的错误

可以使用 react-error-catch这个库来对上面的错误捕获

页面崩溃和卡顿

  1. 使用 loadbeforeunload来对网页奔溃进行监控
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
window.addEventListener("load", function () {
sessionStorage.setItem("good_exit", "pending");
setInterval(function () {
sessionStorage.setItem("time_before_crash", new Date().toString());
}, 1000);
});

window.addEventListener("beforeunload", function () {
sessionStorage.setItem("good_exit", "true");
});

if (
sessionStorage.getItem("good_exit") &&
sessionStorage.getItem("good_exit") !== "true"
) {
/*
insert crash logging code here
*/
alert(
"Hey, welcome back from your crash, looks like you crashed on: " +
sessionStorage.getItem("time_before_crash")
);
}

2.使用 Server Worker来监控网页的崩溃

  • Server Worker有自己独立的工作线程 与网页区分开 即使网页崩溃了 Server Worker一般不会崩溃
  • Server Worker生命周期一般比网页还要长 可以用来监控网页的状态
  • 网页可以通过 navigator.serviceWorker.controller.postMessage API 向掌管自己的 SW 发送消息

小结

异常类型 同步方法 异步方法 资源加载 promise async/await
try/catch ✔️ ✔️
onerror ✔️ ✔️
err 事件监听 ✔️ ✔️ ✔️
unhandledrejection ✔️ ✔️

实际上 我们只要将 unhandledrejection事件抛出的异常再次抛出就可以统一通过error事件进行处理了

1
2
3
4
5
6
7
8
9
10
11
window.addEventListener("unhandledrejection", (e) => {
throw e.reason;
});
window.addEventListener(
"error",
(args) => {
console.log("error event", args);
return true;
},
true
);

行为异常收集

1.点击行为

使用 addEventListener全局监听点击事件 将用户行为(click、input)和 dom元素名字收集

当错误发生将错误和行为一起上报

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const _breadcrumbEventHandler = (evtName) => {
let self = this;
return function (evt) {
if (self._lastCaptureEvent === evt) {
return;
}
self._lastCapturedEvent = evt;
let target;
try {
target = htmlTreeAsString(evt.target);
} catch (e) {
target = "<unknown>";
}
self.captureBreadcrumb({
//在这里捕获到错误
category: `ui.${evtName}`,
message: target,
});
};
};
//全局监听点击
window.addEventListener("click", self._breadcrumbEventHandler("click"), false);

2.发送请求行为

监听XMLHttpRequest对象的onreadystateChange回调函数 在回调函数执行时收集数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function onreadystatechangeHandler() {
if(xhr.__skynet_xhr && xhr.readyState === 4) {
try {
xhr.__skynet_xhr.status_code = xhr.status;
} catch(e) {
//...
}
self.captureBreadcrumb({ //在这里收集到错误
type: 'http',
category: 'xhr',
data: xhr.__skynet_xhr
})
}
}
if('onreadystatechange' in xhr && isFunction(xhr.onreadystatechange)) {
fill(
xhr,
'onreadystatechange',
function(orig) {
return self.wrap(orig, undefined, onreadystatechangeHandler) //
}
)else {
xhr.onreadystatechange = onredystatechcangeHnadler;
}
}

不管是axios还是fetch 底层走的都是XMLHttpRequest 所以不用担心你使用的请求行为捕捉不到

3.页面跳转

监控 window.onpopstate, 页面跳转时会触发次方法 将信息收集

1
2
3
4
5
6
7
8
let oldOnPopState = window.onpopstate; //先将window.onpopstate 赋值给临时变量
window.onpopstate = function (...args) {
let currentHref = location.href;
self._captureUrlChange(self._lastHref, currentHref); //当前url捕获记录
if (oldOnPopState) {
return oldOnPopState.apply(this, args); //原生方法抛出继续执行
}
};

控制台行为

通过改写 console对象的 info | warn | errorconsole执行时将信息收集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let wrapConsoleMethod = (console, level, callback) => {
//...
console[level] = (...args) => {
//...
callback && callback(msg, data);
};
};
let consoleMethodCallback = function (msg, data) {
self.captureBreadcrumb({
message: msg,
level: data.level,
category: "console",
});
};
each(["info", "warn", "error"], (_, level) => {
wrapConsoleMethod(console, level, consoleMethodCallback);
});

AOP:针对业务处理过程中的切面(非业务逻辑部分,例如错误处理 埋点 日志等进行提取)即通过动态的方式将非关注点插入到主关注点中 即改写原生方法 在某些时间段例如 after | before等插入其他方法来达到覆写功能

异常上报和数据清洗

上报方式

1.动态创建 img 标签

可以异常信息拼接在img标签的url上来上报异常

1
new Image().src = 'https://localhost:3000/error'+  '?info=xxxx'

推荐使用 gift 的原因

  • 不会出现跨域问题 不用专门对跨域做配置
  • gift类型的图片体积很小
  • 不需要等待服务器返回数据
  • 无需加载任何库 且页面是无刷新的 不会对用户使用体验造成影响
  • 兼容性好
  • 不会携带当前域名cookie

Q为啥不用请求其他的文件资源(JS| CSS | TTF)的方式上报

A:创建资源节点后只有将插入到DOM树之后浏览器才可以发送实际请求 而且载入 js/css资源还会阻塞特面渲染 影响用户体验 而构造图片打点不仅不用插入DOM 只要在 js 中newImage对象就可以发起请求 而且没有阻塞问题 在不支持js的浏览器中也可以使用

2.Ajax | fetch 上报

3.sendBeacon

navigator.sendBeacon()方法可用于通过 HTTP POST将少量的数据异步传输到Web服务端 主要用于将统计数据发送至服务器 同时避免了用传统技术(XMLHttpRequest)发送分析数据的一些问题(navigator.sendBeacon(url, data) data包括ArrayBuffer/ArrayBufferView/Blob/DOMString/FormData/URLSearchParams

sendBeacon主要用于满足统计和诊断代码的需要 这些代码通常在unload之前像服务器发送数据 过早的发送可能会漏掉一些数据 但是以前的开发者很难保证在文档卸载期间发送数据(一般需要发起一个同步XMLHttpRequest来发送数据 然后创建一个img并设置src以延迟卸载文档 因为还有图像要加载 然后创建一个no-op循环 这都是一种强制延迟文档卸载的方案 会影响下一个导航的呈现) 而sendBeacon是用户代理有机会异步地向服务器发送数据 同时不会延迟页面的卸载或影响下一导航的载入性能 这意味着数据发送是可靠的 数据传输是异步的 不会影响下一导航的载入

如果希望在用户完成页面浏览时向服务器发送分析或诊断数据 最好使用 visibilitychange事件发生时发送数据

1
2
3
4
5
document.addEventListener("visibilitychange", function logData() {
if (document.visibilityState === "hidden") {
navagator, sendBeacon("/log", analyticsData);
}
});

避免在 unload或者 beforeunload事件以在会话结束时发送统计数据 但是这是不可靠的 因为有一些情况下(热别是移动端)是不会触发这些事件的(比如用户加载了网页并与其交互、完成浏览后用户切换到其他原因程序而不是关闭选项卡、用户通过手机的应用管理器关闭了浏览器应用)此外 unload事件和现在推荐的往返缓存(becache)不兼容

可以使用 pagehide来兼容不兼容visibilitychange事件的浏览器

上报的内容

上报的信息内容

error事件参数

  • message: 错误信息
  • filename:异常的资源url
  • lineno:异常行号
  • colno:异常列号
  • error:错误对象
  • error.message:错误信息
  • error: stack:错误信息(错误栈 定义错误最核心的信息)
上报数据序列化

使用字符串形式传送异常信息 所以需要对对象进行序列化处理(后端接收到后再做相应的反序列化处理)

  1. 将异常信息从属性中解构出来存入一个 JSON 对象
  2. 将 JSON 字符串转换为字符串
  3. 将字符串转换为Base64
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function unloadError({
lineno,
colno,
error: { stack },
timeStamp,
message,
filename,
}) {
//过滤
const info = {
lineno,
colno,
stack,
timeStamp,
message,
filename,
};
//序列化
const error = window.btoa(JSON.stringfy(info));
const src = "http://olddog/error";
new Image().src = `${src}?info=${error}`;
}

数据清洗

流程:获取数据 -> 数据预处理 -> 数据聚合

获取数据

可以使用 ES获取数据 ES 底层是基于Lucene的搜索服务器 提供了一个分布式多用户能力的全文搜索引擎 基于RESTful Web接口 只需要像平时开发一样即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//1.比如通过get请求从ES获取近一分钟的错误信息
async getDataFromElasticSearch(type, lastTimestamp) {
const searchData = {
index: elasticConfig.indexs.monitor.index(),
type: elasticConfig.indexes.monitor.type,
body: {
query: {
bool: {
must: [
{
wildcard: {
'eventEs.tag': `*${type}`, //类型
},
},
],
filter: {
range: {
'eventEs.timestamp': {
gt: lastTimestamp, //选择时间范围
},
},
},
must_not: [
{
wildcard: {
'eventEs.tag': '*develop*' //排除开发环境
},
},
],
},
},
},
};
let body = {}
body = await this.elasticClient.search(serachData);
return body; //从ES获取数据
}

设置阈值:为了在大量爆发错误的时候避免服务器过载

  • 设置获取上线:每分钟数据获取上线 1000 跳 超过就采样入库

  • 同类型错误大于 200 条 只统计错误

数据预处理

对于获取到的数据 应该对数据信息进行适当的提取 只将有需要的数据提取出来 并且还要去除原始数据中的无用信息 减少存储体积

数据聚合

聚合的目的:

  1. 存储性能:存储小
  2. 查询性能:查询快

聚合的维度:业务、错误类型、错误信息

最基础的聚合方式就是对上报的全部内容进行一个散列 求 MD5 的值 然后将所有散列值相同的错误聚合成同一类错误(简单粗暴 但是聚合度很低 很多明明是同一种错误被聚合成不同的错误)

数据持久化

在错误大量发生时进行削锋处理 避免监控系统在大量错误爆发时挂掉

例如可以在每次轮询时从ES中拉取最新的 10000 条错误日志 同时聚合过后 同一类型的错误只取部分数量(例如 300 条)写入具体的事件数据库 其他的可以将其增加改类型的错误发生数量即可(这样子可以保证我们处理的永远都是最新的错误 既能避免大量的数据读写 也能让数据处理任务更快完成)

辅助错误分析

针对前端 我们记录了前端用户在页面上发的 ajax 请求、点击事件、跳转事件以及控制台请求,当发生错误时 将这些行为日志和错误日志关联起来 就可以更快地判断用户是在那些操作的时候发生的错误

当我们在进行问题分析时 仅依靠日志上报的信息 很多时候是不够的 因为缺少了了用户浏览路劲以及操作行为等信息 而客户端错误很多都是特定前景下发生的

可以给客户端建立一套日志链路 从客户端的一次冷启动开始 就生成一个链路 id 后续的所有行为日志 网络日志和崩溃日志都会带上这个 id 帮我们记录一些关键节点(页面跳转、网络环境变化、错误的网络请求、用户操作行为等)这样就可以通过这些id直接关联到相关的行为日志 方便后续的错误排查

image-20221031205641480

异常收集

eggjs来写下demo后台

编写 error 上传接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
//添加一个路由用来接收上报的异常信息
//app/router,js
module.exports = app => {
const {router, controller} = app;
router.get('/', controller.home.index);
//创建一个新的路由
router.get('/monitor/error', controller.monitor.index)
//添加上传路由
router.post('/monitor/sourcemap', controller.monitor.uplaod)
}
//创建controller控制层
//app/controller/monitor
import {Controller} from 'egg';
import {getOriginSource} from '../utils/sourcemap';
import fs from 'fs'
import path from 'path'
class MonitorController extends Controller {
async index() {
const {ctx} = this;
cost {info} = ctx.query;
const json = JSON.parse(Buffer.from(info, 'base64').toString('utf-8'))
console.log('froneterror', json);
ctx.body = '';
}
//添加上传接口
async upload() {
const {ctx} = this
const stream = ctx.req;
const filename = crx.query.name
const dir = path.join(this.config.baseDir, 'uploads')
//判断uplaod目录是否存在
if(!fs.existSync(dir)) {
fs.mkdirSync(dir)
}
const target = path.join(dir, filename);
const writeStream = fs.createWriteStream(target);
stream.pipe(writeStream)
}
}
module.exports = MonitorController;
//记录日志文件
//增加错误日志
//config/config.defult.js
config.customLogger = {
frontentLogger: {
file: path.join(appInfo.root, 'logs/fronted.log'
}
}
// app/controller/monitor.js添加日志记录
async index() {
const { ctx } = this;
const { info } = ctx.query
const json = JSON.parse(Buffer.from(info, 'base64').toString('utf-8'))
console.log('fronterror:', json)
// 记入错误日志
this.ctx.getLogger('frontendLogger').error(json)
ctx.body = '';

}

image-20200206171529549

异常分析

webpack

webpack 插件实现 SourceMap 上传

webpack构建时生成sourcemap文件 将文件上传到异常监控服务器

1.创建 webpack 插件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import fs from "fs";
import http from "http";
class UploadSourceMapWebpackPlugin {
constructor(options) {
this.options = options;
}
apply(compiler) {
//在打包完成后执行
compiler.hooks.done.tap("upload-sourcemap-plugin", async (status) => {
//读取sourcemap文件
const list = glob.sync(
path.join(status.compilation.outputOptions.path, "./**/*.${js.map}")
);
for (let filename of list) {
//依次上传文件
await this.upload(this.options.uploadUrl, filename);
}
console.log("webapck running");
});
}
//上传文件的功能
upload(url, file) {
return new Promise((resolve) => {
console.log("upload", file);
const req = http.request(`${url}?name=${path.basename(file)}`, {
method: "POST",
headers: {
"Content-Type": "application/octest-stream",
Connection: "keep-alive",
"Transfer-Encoding": "chunked",
},
});
fs.createReadStream(file)
.on("data", (chunk) => {
req.write(chunk);
})
.on("end", () => {
req.end();
resolve();
});
});
}
}
module.exports = UploadSourceMapWebpackPlugin;
2.加载 webpack 插件
1
2
3
4
5
6
7
8
9
10
//webpack.config.js
import UploadSourceMapWebpackPlugin form './plugin/uploadSourceMapWebpakcPlugin'
//...
plugins: [
//添加自动上传插件
new UploadSourceMapWebpackPlugin({
uploadUrl: 'https://olddog/monitor/sourcemap',
apiKey: 'olddog'
})
]

这样子执行 webpack 打包时调用插件sourcemap将被上传至服务器

image-20200206202732716

vite

由于vite使用rollup作为模块打包器 所以可以编写rollup插件 在打包完成后读取所有sourcemap上传到后台 并且将打包输出目录中的sourcemap文件删除 减少线上环境的资源请求

1.编写插件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import glob from 'glob';
import path from 'path';
import fs from 'fs';
import htrp from 'http';
export default function uploadSourceMap({
//基础接口地址
baseUrl,
//处理目标文件夹接口地址
handleTargetFolderUrl,
//上传sourceMap文件地址
uploadUrl
}) {
return {
name: 'upload-sourcemap',
//打包完成的钩子
closeBundle() {
console.log('closeBundle');
//获取当前环境
let env = 'uat';
if(baseUrl === '生产环境的url') {
env = 'prod'
}
//上传文件的方法
function upload(url, file, env) {
return new Promise(resolve => {
const req = http.request(
`${url}?name=${path.basename(file)}&&env=${env}`,
{
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();
resolve('end')
})
}
};
//除了目标文件夹(没有就创建 有就清空)
function handleTargetFolder() {
http.get(`${handleTargetFolderUrl}?env=${env}`, () => {
console.log('handleTargetFolderUrl success')
})
.on('error', (e) => {
console.log('handle folder err', e)
})
}
handleTargetFolder();
//读取sourcemap文件并删除
async function uploadDel() {
const list = glob.sync(path.join('./dist', './**/*.{js.map}'));//读取sourcemap
for(let filename of list) {
await upload(uploadUrl, filename, env);
await fs.unlinkSync(filename);
}
}
uploadDel()
}
}

2.引入插件并使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//vite.config.js
import { loadEnv } from "env";
import vue from "@vitejs/plugin-vite";
import uploadSourceMap from "./src/plugins/rollup-plugin-upload-sourcemap";
export default ({ mode }) => {
const env = loadEnv(mode, process.cwd());
return {
server: {
open: true,
port: 3000,
host: "0.0.0.0",
proxy: {
// 本地测试异常监控用
"/mointor": {
target: "http://127.0.0.1:7001",
changeOrigin: true,
},
},
},
plugins: [
vue(),
uploadSourceMap({
//基本路径 判断当前环境使用
baseUrl: env.VITE_BASE_API,
//处理目标文件夹接口地址
handleTargetFolderUrl: `${env.VITE_MONITOR_UPLOAD_API}/emptyFolder`,
// 上传sourcemap文件接口地址
uploadUrl: `${env.VITE_MONITOR_UPLOAD_API}/uploadSourceMap`,
}),
],
build: {
//构建后生成sourcemap文件
sourcemap: true,
},
};
};

3.后台服务接收上传的 sourcemap 文件并保存

1
2
3
4
5
6
7
8
9
10
11
async uploadSourcemap() {
const {ctx} = this;
const stream = ctx.req,
filename = ctx.query.name,
env = ctx.query.env;
//要上传的目标路径
const dir = path.join(this.config.baseDir, `upload/${env)`);
//目标文件 const target = path.join(dir, filename);
//写入文件内容 const writeStream = fs.createWriteStream(target)
stream.pipe(writeStream)
}

4.利用 errorHandler 进行异常捕获并上送报错信息到后台

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
//main.js
import handleError from './utils/monitor';
const app = createApp(App);
//异常监控上送报错信息 接口地址
handleError(app, import.meta.env.VITE_MOBNITOR_REPORT_API);
//monitor.js
import axios from "axios";
// 获取浏览器信息
function getBrowserInfo() {
const agent = navigator.userAgent.toLowerCase();
const regIE = /msie [\d.]+;/gi;
const regIE11 = /rv:[\d.]+/gi;
const regFireFox = /firefox/[\d.]+/gi;
const regQQ = /qqbrowser/[\d.]+/gi;
const regEdg = /edg/[\d.]+/gi;
const regSafari = /safari/[\d.]+/gi;
const regChrome = /chrome/[\d.]+/gi;
// IE10及以下
if (regIE.test(agent)) {
return agent.match(regIE)[0];
}
// IE11
if (regIE11.test(agent)) {
return "IE11";
}
// firefox
if (regFireFox.test(agent)) {
return agent.match(regFireFox)[0];
}
// QQ
if (regQQ.test(agent)) {
return agent.match(regQQ)[0];
}
// Edg
if (regEdg.test(agent)) {
return agent.match(regEdg)[0];
}
// Chrome
if (regChrome.test(agent)) {
return agent.match(regChrome)[0];
}
// Safari
if (regSafari.test(agent)) {
return agent.match(regSafari)[0];
}}
// 捕获报错方法
export default function handleError(Vue,baseUrl) {
if (!baseUrl) {
console.log("baseUrl", baseUrl);
return;
}
Vue.config.errorHandler = (err, vm) => {
// 获取当前环境
let environment = "测试环境";
if (import.meta.env.VITE_BASE_API === "production_base_api") {
environment = "生产环境";
}
// 发送请求上送报错信息
axios({
method: "post",
url: `${baseUrl}/reportError`,
data: {
environment,
location: window.location.href,
message: err.message,
stack: err.stack,
// 浏览器信息
browserInfo: getBrowserInfo(),
// 以下信息可以放在vuex store中维护
// 用户ID
userId:"001",
// 用户名称
userName:"张三",
// 路由记录
routerHistory:[
{
fullPath:"/login",
name:"Login",
query:{},
params:{},
},{
fullPath:"/home",
name:"Home",
query:{},
params:{},
}
],
// 点击记录
clickHistory:[
{
pageX:50,
pageY:50,
nodeName:"div",
className:"test",
id:"test",
innerText:"测试按钮"
}
],
},
});
};}

4.后台收到报错结合 sourcemap 文件解析错误通知开发人员

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 前端报错,上报
errorasync reportError() {
const { ctx } = this;
const { environment, location, message, stack, browserInfo, userId, userName, routerHistory, clickHistory } = ctx.request.body;
let env = '';
if (environment === '测试环境') {
env = 'uat';
} else if (environment === '生产环境') {
env = 'prod';
}
// 组合sourcemap文件路径
const sourceMapDir = path.join(this.config.baseDir, `upload/${env}`);
// 解析报错信息
const stackParser = new StackParser(sourceMapDir);
let routerHistoryStr = '<h3>router history</h3>',
clickHistoryStr = '<h3>click history</h3>';
// 组合路由历史信息
routerHistory && routerHistory.length && routerHistory.forEach(item => {
routerHistoryStr += `<p>name:${item.name} | fullPath:${item.fullPath}</p>`;
routerHistoryStr += `<p>params:${JSON.stringify(item.params)} | query:${JSON.stringify(item.query)}</p><p>--------------------</p>`;
});
// 组合点击历史信息
clickHistory && clickHistory.length && clickHistory.forEach(item => {
clickHistoryStr += `<p>pageX:${item.pageX} | pageY:${item.pageY}</p>`;
clickHistoryStr += `<p>nodeName:${item.nodeName} | className:${item.className} | id:${item.id}</p>`;
clickHistoryStr += `<p>innerText:${item.innerText}</p><p>--------------------</p>`;
});
// 通过上送的sourcemap文件,配合error信息,解析报错信息
const errInfo = await stackParser.parseStackTrack(stack, message);
// 获取当前时间
const now = new Date();
const time = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()} ${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}`;
// 组织邮件正文
const mailMsg = `
<h3>message:${message}</h3>
<h3>location:${location}</h3>
<p>source:${errInfo.source}</p>
<p>line::${errInfo.lineNumber}</p>
<p>column:${errInfo.columnNumber}</p>
<p>fileName:${errInfo.fileName}</p>
<p>functionName:${errInfo.functionName}</p>
<p>time::${time}</p>
<p>browserInfo::${browserInfo}</p>
<p>userId::${userId}</p>
<p>userName::${userName}</p>
${routerHistoryStr}
${clickHistoryStr}
`;
// sendMail('发件箱地址', '发件箱授权码', '收件箱地址', 主题 environment, 正文 mailMsg);
sendMail('发件箱地址', '发件箱授权码', '收件箱地址', environment, mailMsg);
ctx.body = {
header: {
code: 0,
message: 'OK',
}
};
ctx.status = 200;
}

5.发送邮件方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
"use strict";
const nodemailer = require("nodemailer");
// 发送邮件方法
function sendMail(from, fromPass, receivers, subject, msg) {
const smtpTransport = nodemailer.createTransport({
host: "smtp.qq.email",
service: "qq",
secureConnection: true,
// use SSL
secure: true,
port: 465,
auth: {
user: from,
pass: fromPass,
},
});
smtpTransport.sendMail(
{
from,
// 收件人邮箱,多个邮箱地址间用英文逗号隔开
to: receivers,
// 邮件主题
subject,
// 邮件正文
html: msg,
},
(err) => {
if (err) {
console.log("send mail error: ", err);
}
}
);
}
module.exports = sendMail;

根据 sourcemap 分析异常信息

将分析的功能开发成单独的函数并用 jest 做单元测试

需求: 输入 -> sourceMap 查找 -> 源码错误栈并返回信息

1.创建/utils/stackparser.js

1
2
3
4
5
6
module.exports = class StackParser = {
constroutor(sourceMapDir) {
this.consumers = {}
this.sourceMapDir = sourceMapDir
}
}

2.反序列 Error 对象

1
2
3
4
5
6
7
const ErrorStackParser = require('error-stack-parser');
parserStackTrack(stack, message) {
const error = new Error(message)
error.stack = stack
const stackFrame = ErrorStackParser.parse(error)
return stackFrame
}

3.创建测试文件 stackparser.spec.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const StackParser = require("../stackparser");
const { resolve } = require("path");
const error = {
stack:
"ReferenceError: xxx is not defined\n" +
" at http://localhost:7001/public/bundle.e7877aa7bc4f04f5c33b.js:1:1392",
message: "Uncaught ReferenceError: xxx is not defined",
filename: "http://localhost:7001/public/bundle.e7877aa7bc4f04f5c33b.js",
};

it("stackparser on-the-fly", async () => {
const stackParser = new StackParser(__dirname);

// 断言
expect(originStack[0]).toMatchObject({
source: "webpack:///src/index.js",
line: 24,
column: 4,
name: "xxx",
});
});

4.将错误栈中的代码位置转换为源码位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import {SourceMapConsumer} from 'source-map';
async getOriginalErrorStack(stckFrame) {
const origin = [];
for(let v of stackFrame) {
origin.push(await this.getOriginPosition)
}
//销毁所有的comsumenr
Object.keys(this.cosumer).forEach( key => {
console.log('key', key);
this.consumers[key].destory()
})
return origin
}
async getOriginPosition(stackFrame) {
let {columnNumber, lineNumber, fileName} = stackFrame;
fileName = path.basename(filename);
console.log('filename', fileName);
//判断是否存在
let consumer = this.consumer[fileName];
if( consumer == undefined) {
//读取sourcemap
const sourceMapPath = path.resolve(this.sourceMapDir, fileName + '.map');
//判断目录是否存在
if(!fs.existsSync(sourceMapPath)) {
return stackFrame
}
const content = fs,readFileSync(sourceMap, 'utf8')
consumer = await new SourceMapConsumer(content, null);
this.consumer[fileName] = consumer
}
const postData = consumer.originalPositionFor({line: lineNumber, column: columnNumber})
return postData;
}
//将源码位置计入日志
async index() {
console.log
const { ctx } = this;
const { info } = ctx.query
const json = JSON.parse(Buffer.from(info, 'base64').toString('utf-8'))
console.log('fronterror:', json)

// 转换为源码位置
const stackParser = new StackParser(path.join(this.config.baseDir, 'uploads'))
const stackFrame = stackParser.parseStackTrack(json.stack, json.message)
const originStack = await stackParser.getOriginalErrorStack(stackFrame)
this.ctx.getLogger('frontendLogger').error(json,originStack)

ctx.body = '';
}

5.接下来使用 jest 测试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
it("stackparser on-the-fly", async () => {
const stackParser = new StackParser(__dirname);
console.log("Stack:", error.stack);
const stackFrame = stackParser.parseStackTrack(error.stack, error.message);
stackFrame.map((v) => {
console.log("stackFrame", v);
});
const originStack = await stackParser.getOriginalErrorStack(stackFrame);

// 断言
expect(originStack[0]).toMatchObject({
source: "webpack:///src/index.js",
line: 24,
column: 4,
name: "xxx",
});
});

文章作者: olddog
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 olddog !
  目录