Vue3异常监控

2,678 阅读4分钟

本文主要记录 Vue3 项目部署后如果发生报错及异常,如何获取到详细的错误信息,并自动通知相关开发人员进行处理。《项目地址》,让我们开始吧!

痛点

通常我们在本地进行开发的时候,如果发生错误或者异常,Vue3 会在浏览器给我们一个堆栈错误信息的提示

并且可以看到该错误发生在哪个组件,哪个文件的哪一行

点击文件会在控制台 Sources 打开该文件并定位到错误位置

这极大的方便了我们查找错误原因的过程,但是当我们的代码部署到服务器上的时候,通常为了项目优化,不会进行 sourcemap 文件的打包,这个时候,错误信息通常是这个样子的

是不是一脸茫然?点开对应的文件也是打包压缩后的代码,根本无从查看错误信息并对应到源代码的具体文件及具体发生错误位置。 不仅如此,当你收到业务反馈的问题,但是自己打开浏览器一番操作,并没有遇到问题的时候,是不是觉得无从下手了呢? 接下来我们要做的就是当线上环境报错的时候,收集错误信息,并及时通知开发人员。

浏览器中捕获异常通常通过以下方法

addEventListener('error', callback)||window.onerror= callback

这里不再赘述。

Vue3 向我们提供了API errorHandler 进行异常捕获。

说明:我的后台服务是用 Node.jseggjs 框架创建的,你可以用任何自己熟悉的后端语言和框架编写,逻辑也很简单。

主要功能点:

1. 打包时生成sourcemap文件上传到后台

这里我采用了 Vite 创建 Vue3 项目,因为 Vue3 使用 rollup 作为模块打包器,所以这里需要编写一个 rollup 插件,作用是打包完成后读取所有sourcemap 上传到后台,并将打包输入目录中的 sourcemap 文件删除,减少线上环境的资源请求

vite.config.js 中引入及使用

import { loadEnv } from "vite";
import vue from '@vitejs/plugin-vue'
// 引入upload sourcemap rollup plugin
import uploadSourceMap from "./src/plugins/rollup-plugin-upload-sourcemap";
// mode 当前环境 development production
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(),
      // 使用upload sourcemap rollup plugin
      uploadSourceMap({
        // 基本路径,判断当前环境使用
        baseUrl: env.VITE_BASE_API,
        // 处理目标文件夹接口地址
        handleTargetFolderUrl: `${env.VITE_MONITOR_UPLOAD_API}/emptyFolder`,
        // 上传sourcemap文件接口地址
        uploadUrl: `${env.VITE_MONITOR_UPLOAD_API}/uploadSourceMap`,
      }),
    ],
    build: {
      // 构建后是否生成 source map 文件
      sourcemap: true,
    }
  }}

rollup-plugin-upload-sourcemap.js

import glob from "glob";
import path from "path";
import fs from "fs";
import http from "http";
export default function uploadSourceMap({
  // 基础接口地址
  baseUrl,
  // 处理目标文件夹接口地址
  handleTargetFolderUrl,
  // 上传sourcemap文件地址
  uploadUrl
}) {
  return {
    name: "upload-sourcemap",
    // 打包完成后钩子
    closeBundle() {
      console.log('closeBundle');
      // 获取当前环境
      let env = "uat";
      if (baseUrl === "production_base_api") {
        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 error: ${e.message}`);
          });
      }
      handleTargetFolder();
      // 读取sourcemap文件 上传并删除
      async function uploadDel() {
        const list = glob.sync(path.join("./dist", "./**/*.{js.map,}"));
        for (const filename of list) {
          await upload(uploadUrl, filename, env);
          await fs.unlinkSync(filename);
        }
      }
      uploadDel();
    },
  };
}

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

// 前端打包时,上送sourcemap文件
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);
}

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

main.js 中引入及使用

import handleError from "./utils/monitor";
const app = createApp(App)
// 异常监控上送报错信息    接口地址
handleError(app, import.meta.env.VITE_MONITOR_REPORT_API);

mointor.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文件解析错误信息通知开发人员

// 前端报错,上报
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. 发送邮件方法

'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;

End

至此,Vue3 的异常监控就完成了,后续会写 Vue3 项目从零到一创建及搭建一个基于 Vite+Vue3+Vue Router+Vuex+TS+Element3+axios+Jest+Cypress 的后台管理系统,感兴趣的点个关注吧!

关于本文有任何问题或建议,欢迎留言讨论!