撸一个前端监控系统(React + Node + Mysql + Webpack plugin + Docker)—— (上)

前言

俗话说,不依赖业务场景的系统设计,叫耍流氓。
构想设计这个系统也是有着业务背景的,所在公司的产品既面向 C 端,也面向 B 端。在咱们开发落地业务时,常常遇到一类很是头疼的问题:css

客户:这个页面怎么报错了?让大家开发给看一下。

客户:为何这个模块没数据,控制台还有报错,怎么回事?

苦逼coder: 请问您能具体描述一些出错的场景和步骤吗?最好能帮忙截图一些页面细节。

等客户操做半天, 给来一堆有用没用的信息......

感叹!写代码为何这么难,业务怎么这么让人头疼...
复制代码

因此我决定动手撸一个监控系统,用来解决错误收集和问题回溯。让咱们在追溯以往出现的问题时,可以省时省力,而且可以统计到系统的漏洞。html

阅读收益

“ 我读你写的文章,对我有什么用?”前端

  • 能够掌握如何 收集前端错误
  • 学习如何设计一个 webpack 插件
  • 如何调试插件
  • 了解 Node服务端 的基本内容
  • 了解 数据库 的基本操做
  • 了解 docker 的简单使用
  • 起一个 nginx 服务器
  • 学会一种姿式本身 搭建一个生产环境
  • 学会 部署 服务

系统设计

系统设计的基本思路不一样于解决业务逻辑,系统设计上,咱们应该 从大到小,由总体到局部,思考如下问题:node

步骤 问题 方案
1 咱们遇到了什么问题 ? 前端错误难以追溯
2 解决问题的大体思路 收集错误,分析错误,展现错误
3 将解决方案转换为系统模型 咱们须要一整套可以作错误收集, 存储, 分析, 展现的系统
4 拆分子系统 前端系统;后端系统;插件系统;
5 子系统模块分割 根据各系统特性和解决的具体问题做划分
6 模块实现 撸代码
7 系统串联调试 系统间串联调试
8 系统优化 思考已实现的内容能不能解决最初问题,哪里还能更好 ?

这个系统咱们将从前端收集错误,上传至服务器,经由服务器解析存储并提供消费接口,并在必定程度上解析 source-map 来输出源码错误信息。mysql

基于以上,咱们搭建一个前端工程做为实验室,来生产错误数据, 经过 webpack plugin 来作 source-map 上传,在服务端进行解析。服务端咱们以 node 做为开发语言,并选用 mysql 数据库来存储错误信息,最终将收集到的错误展现到前端。react

前端实现

为了可以快速的搭建一个前端工程,咱们选用 Create-React-App 做为脚手架来初始化项目。webpack

1. 先安装 Cra

npm i -g  create-react-app

2. 初始化项目

npx create-react-app react-repo

3. 启动应用

cd react-repo
npm start

复制代码

到这里都很简单,更多内容见官网ios

错误类型

对于前端出现的错误,咱们分为两类,一类是 页面错误, 如一系列致使页面异常,页面白屏的错误;一类是 网络错误,即因为服务端异常所致使的错误,或者不符合既定先后端约束的错误。nginx

错误信息结构

错误信息消息体,包含如下信息:git

  • 用户信息
  • 错误信息
  • 用户设备信息

其中 用户信息错误信息 咱们须要本身拼装上传,用户设备信息咱们能够在服务端获取,无需上传浪费资源。

/** */
interface ErrorInfo {
  /** * 用户id */
  userId: string;
  /** * 帐户名称 */
  username: string;
  /** * 租户 */
  tenant: string;
  /** * 请求源地址 */
  origin: string;  
  /** * 用户设备 */
  userAgent: string;
  /** * 错误信息单元 */
  error: {
    /** * 错误信息 */
    message: string;
    /** * 错误栈,详细信息 */
    stack: string;
    /** * 错误文件名称 */
    filename: string;
    /** * 错误行 */
    line?: number;
    /** * 错误列 */
    column?: number;
    /** * 错误类型 */
    type: string;
  };
  // 发生错误的时间戳
  timestamp: number;
};
复制代码

拦截全局错误

最早想到的是,处理全局错误,在浏览器环境中,咱们能够监听 onError 事件

window.addEventListener(
    "error",
    (msg, url, row, col, error) => {
      // 错误信息处理通道
      processErrorInfo(msg, url, row, col, error);
    },
  );
复制代码

这里使用 addEventListener, 能够保证不影响其余监听 error 事件的事务执行。

使用 ErrorBoundary

在 React 中,有一个 componentDidCatch 的生命周期,它可以捕获子层组件抛出的错误,在这里咱们利用它来捕获内层组件的错误,并增长友好性错误提示。

componentDidCatch(error, errorInfo) {
    processErrorInfo(error);
}
复制代码

ErrorBoundary 仅能捕获未被内层捕获的错误,在一些逻辑清晰的组件中,咱们能够经过逻辑判断来主动上报错误,依然使用 processErrorInfo 的错误信息处理通道。

拦截网络错误

在这个项目中,我使用的 axios 做为咱们的 ajax 库,它提供 interceptor 拦截器来预处理 request 和 response,因此咱们能够在这里进行统一的网络错误拦截。

建议在咱们的项目中对于 ajax 都进行统一封装,这样在对于请求作一致化处理时很是方便。

import axios from "axios";

axios.interceptors.response.use(
  response => response,
  error => {
    // 对网络错误进行拦截
    processErrorInfo(error);
    return Promise.reject(error);
  }
);
复制代码

这里选择在错误拦截后,依然继续抛出错误是为了保证请求的连贯性,由于在具体的业务层面咱们有可能须要对错误信息进行一些处理。固然您也能够根据具体的业务作相应的调整。

错误格式化

观察以上的几层拦截方式,能够发现,咱们都使用了一个 processErrorInfo 的函数。因为咱们收集到的错误类型众多,所以须要进行格式化,而后再上传到服务器。

// 生成 YYYY-MM-DD hh:mm:ss 格式的时间
function datetime() {
  const d = new Date();
  const time = d.toString().replace(/.*(\d{2}:\d{2}:\d{2}).*/, "$1");
  const day = d.toJSON().slice(0, 10);
  return `${day} ${time}`;
}

// 生产最终的上报数据,包含了用户信息和错误信息
const processErrorInfo = (info) => {
  let col;
  let line;
  let filename;
  let message;
  let stack;
  let type = "error";

  if (info instanceof ErrorEvent) {
    col = info.colno;
    line = info.lineno;
    filename = info.filename;
    message = info.message;
    stack = info.error.stack;
    type = info.type;
  } else if (info instanceof Error) {
    message = info.message;
    stack = info.stack;
  }
  
  // 伪造一份用户信息
  // 这里应该对接咱们实际业务中的用户信息
  const userInfo = {
    user_id: "ein", // 用户id
    user_name: "ein@mail.com",  // 用户名称
    tenant: "mail" // 租户名称
  }

  return {
    ...userInfo,
    col,
    line,
    filename,
    message,
    stack,
    type,
    timestamp: datetime()
  };
}
复制代码

组装完错误信息后,下面进行错误上报。

错误上传

/** * @param {格式化后的错误信息} error */
export const uploadError = error => {
  axios
    .post("/errors/upload", JSON.stringify(data))
    .then(res => {
      console.log("upload result", res);
    })
    .catch(err => {
      console.log("upload error", err);
    });
};
复制代码

咱们设定的后端路由 /errors/upload 来接收错误信息, 到这里,前端收集,格式化,上传错误的步骤就基本完成了。

在这些错误信息中,最重要的就是 stack 字段了,它包含了咱们出错的具体信息,这一部分必定不能缺失。

细心的同窗可能发现,咱们上传的错误信息中缺乏了col, line, filename 几个字段,这几个字段是错误的文件名和行列号,即出错的具体位置。在有些场景下,咱们时没法从回调事件参数中直接获取这几个字段的,但也不是没有办法解决,怎么解决?咱们继续往下看。

服务端实现

首先思考一个问题:为何这个系统中咱们须要一个服务端 ? 能不能纯前端完成这个系统 ?

“ 浏览器不是有 localStorage, sessionStorage 这样的 API 吗? 也有 indexdb 这样的浏览器数据库。
  咱们能够用它来存储错误,而后进行集中展现。 ”
复制代码

可能有的同窗有以上的疑问 ? 在浏览器环境当中,咱们一直缺失一个数据持久层,所以在浏览器的不断演进当中,添加了一些可以用来存储数据的 API。

可是这些存储方式自己有存储量的限制,再者,用户使用的浏览器丰富多样,咱们如何同步这些数据 ? 如何保证接口的一致性 ?

咱们须要的是一个可以面向所有用户的数据存储设施,而且可以知足高的并发量,所以须要一个后端服务来完成这个工做。

综上考虑,咱们选用 Node 做为后端开发语言,第一它对于并发量有很好的支持,第二 Node 对于前端来讲容易上手,数据库咱们选用 mysql 来实现。

搭建基础设施

要作后端服务,首先咱们须要搭建一个 node 工程。在这里,我选用 Koa2 做为后端框架,这是 Express 原团队打造的 node 框架,支持 async 语法。固然你也能够选择 ExpressEgg

脚手架工具可使用 koa-generator 或其余可选项快速生产一个 Node 项目骨架。

建议 不熟悉服务端开发或者不熟悉 Node 开发的同窗自行搭建一个工程,这里咱们本身来搭建一个工程。

1. 建立一个工程目录并初始化
mkdir error-monitor-node-server
cd error-monitor-node-server
npm init
git init

2. 安装依赖项

// 咱们先安装核心的几个依赖
npm i koa koa-router mysql -S

3. 目录结构

- config  // 系统相关设置
- controller  
- logs   // 日志
- middleware  // 中间件 
- mysql  // 数据库 
- routers  // 路由
- utils  // 工具类
index.js // 入口文件
复制代码

具体的依赖见下图,后续咱们都会用到,能够提早安装好或者用的时候再安装均可。

在系统中,咱们用到了许多 ES 高版本语法,所以须要引入 babel 来作语法转换。

Koa2

koa2 封装了 Node 原生 API, 主要处理了 request 和 response 部分。提供了一个被称为 context 的运行时变量,将一些经常使用的操做挂载在了这个对象属性上。并约定了中间件的组织方式,以著名的 洋葱模型 顺序来执行中间件。

有兴趣的同窗能够阅读一下 源码,比较简短精炼,其中 koa-compose 包是中间件的实现。

错误获取接口

const router = require("koa-router")();
const errorsController = require("../controller/c-error");

// 上传错误信息
router.post("/errors/upload", errorsController.uploadErrors);
复制代码
const uploadErrors = async ctx => {
  try {
    // request body
    const body = ctx.request.body;
    // 将错误信息写入表中
    await ctx.mysql.whriteError(body);
    ctx.response.type = "json";
    ctx.body = "true";
  } catch (e) {
    ctx.statusCode = 500;
    ctx.response.type = "json";
    ctx.body = "false";
  }
};
复制代码
const Koa = require("Koa");
const mysql = require("./mysql/index");

const app = new Koa();

app.context.mysql = mysql;
复制代码

这里咱们使用 koa-router 来处理路由,并将 router 和 controller 分开,这样让结构保持清晰。

uploadError 函数来处理具体的业务逻辑,这里咱们接受请求传来的参数,即咱们上面在前端经过接口传过来的错误信息。

这里的 ctx 就是咱们上面提到的 context,它会在中间件之间传递。咱们将 mysql 的一个实例也绑定在了 ctx 上,这样就不须要每一个文件进行 require 操做。

mysql

下来咱们须要编写数据库部分的逻辑了。

安装 mysql ---

npm i mysql -S

数据库配置 ---

databaseConfig: {
    database: "error_monitor_ci",
    user: "root",
    password: "1234567890",
    host: "localhost"
}
  
操做数据库 --- 

const mysql = require("mysql");
const { databaseConfig } = require("../config/default");
const sqls = require("./sqls");
const { logger } = require("../middleware/log");

const connection = mysql.createConnection(databaseConfig);

class MySQL {
  constructor() {
    this.table = "errors";
    this.init();
  }

  init = () => {
    // 初始化表
    connection.query(sqls.createTable(this.table), (err, res, fields) => {
      if (err) {
        logger.error("connect errors table failed...", err);
      }
    });
  };

  whriteError = error =>
    new Promise((r, j) => {
      connection.query(sqls.writeError(this.table), error, (err, res) => {
        if (err) {
          logger.error(err);
          j(err);
        } else {
          r(res);
        }
      });
    });
}
复制代码

能够看到,操做数据库,咱们分为了如下几部:

  1. 链接数据库
  2. 建表
  3. 执行插入语句

将 sql 语句,咱们单独提了出来,若是没有扩展的计划,也能够将 sql 语句和数据库操做逻辑放在一块儿。

/** * 注意: * 1. 表名带引号为小写,不带默认大写 * 2. 列名带引号为小写,不带默认大写 * 3. 字段类型标注须要大写 * 4. 建表语句末尾不能加逗号 * 6. 默认取时间戳 current_timestamp * 7. 长文本不适合用char来存储,能够选择使用txt类型 */
module.exports = {
  createTable: tb => `create table if not exists ${tb}( id int primary key auto_increment, user_id varchar(255) not null, user_name varchar(255) not null, tenant varchar(255) not null, timestamp datetime default now(), col int(1), line int(1), filename varchar(255) , message varchar(255) not null, stack text not null, type varchar(255) not null, sourcemap text ) engine=InnoDB auto_increment=0 default charset=utf8`,
  writeError: tb => `INSERT INTO ${tb} SET ?`,
};
复制代码

实例化 MySQL 类的时候,咱们会先执行一个 init 方法,这个时候会进行建表操做。

当表不存在的时候,咱们会进行建表操做。

writeError 就是刚才咱们在接受到 /errors/upload 请求时,执行的 ctx.mysql.whriteError 方法。

CORS

下面这张图,有没有很熟悉 ?

因为咱们的前端和服务端在两个端口运行,因此在调用接口的时候,会遇到跨域问题。

不用慌,咱们能够用下面这个姿式解决。

const Koa = require("Koa");
const cors = require("koa2-cors");
const app = new Koa();

app.use(
  cors({
    origin: "*",
    credentials: true, //是否容许发送Cookie
    allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], //设置所容许的HTTP请求方法
    allowHeaders: ["Content-Type", "Authorization", "Accept"] //设置服务器支持的全部头信息字段
  })
);
复制代码

这里咱们本地开发,能够设置 cors origin 为 “ * ”。可是 切忌 不能在生产环境这么设置,必定要指定生产环境的前端域名,不然,你的服务将会很容易遭受到攻击。

查询错误列表

如今,咱们上传错误的部分基本完成了。下来还须要一个查询错误列表的接口,提供给前端来展现错误信息。下来让咱们完成这一部分:

增长一条路由控制 ---

// 获取错误信息
router.get("/errors/list", getErrors);

复制代码
// 获取错误列表
getErrors = async ctx => {
  const webErrors = await ctx.mysql.query();

  ctx.body = {
    code: 200,
    msg: "success",
    data: webErrors
  };
}
复制代码
// mysql
query = () =>
    new Promise((r, j) => {
      connection.query(sqls.all(this.table), (err, res) => {
        if (err) {
          logger.error(err);
          j(err);
        } else {
          r(res);
        }
      });
    });
    
sqls.all = tb => `SELECT * from ${tb}`,
复制代码

如今查询错误列表的接口就完成了,前端咱们作一些简单的展现组件来显示这些信息。

getList = () =>
  axios
    .get("/errors/list")
    .then(res => res.data.data)
    .catch(err => []);
复制代码
const request = async setList => {
  const list = await getList();
  setList(list);
};

function App() {
  const [list, setList] = useState([]);

  useEffect(() => {
    request(setList);
  }, []);

  return (
    <div className="App"> <header className="App-header"> <p>Error Monitor</p> <List list={list} /> </header> </div> ); } 复制代码

能够看到咱们上传的信息,都经过接口获取到了。

观察仔细的同窗可能发现上面有一行 错误原始文件 的信息行,它是什么呢?咱们继续往下看。

source-map 插件

到这里,咱们的前端,后端和数据库已经完成了,整个错误信息上报的过程已经打通。可是你们能够看到,错误栈信息,里面是一堆 chunk.js 文件。

如今咱们前端开发大都会使用 React, Vue, Less, Sass 这些框架或库,以及许多新版本的语法,以及一堆五花八门的三方依赖 SDK。

但在服务部署到线上时,都会对代码进行分块打包,压缩合并。所以线上环境的代码不可以知足咱们去分析错误缘由。

因此,咱们还须要对这些压缩后的信息,进行还原,这样才可以准确的判定错误的具体位置。

“ 若是可以像浏览器这样,显示具体的错误位置,那简直太好了 ”
复制代码

问题分析

针对以上愿景,咱们来作一下分析,肯定咱们的解决方案。

1. 咱们想要什么 ?

咱们想要肯定错误的具体位置

2. 咱们有什么 ?

咱们有压缩后的错误信息

3. 咱们能够作什么 ?

尝试经过压缩后的信息,还原出来原始的错误信息

复制代码

基于以上的思路, 在社区调研以后, 咱们决定经过解析 source-map 来获得咱们的原始出错信息。

步骤 操做
1 在打包时收集 map 文件
2 将 map 文件上传到服务器
3 在接收到前端上报的错误时分析出原始文件信息
4 将原始错误信息入库
5 在前端获取错误列表时,一并返回原始错误信息

因为咱们的 source-map 文件是在打包阶段生产出来的,因此咱们不防设计一个插件来完成这个工做。

webpack plugin

咱们的前端项目经过 webpack 打包,因此咱们来设计一个 webpack 插件来完成 source-map 上传的工做。

要设计 webpack 插件,让我先简单了解下 webpack 插件

webpack 插件用于在打包期间来扩展 webpack 打包行为。

若是你在使用 webpack,可能对 html-webpack-plugin, HappyPack, DllReferencePlugin 这些已经比较熟悉了。
复制代码

在设计层面上,咱们依然保持自上而下的构建思路,先描述咱们的接口,再编写具体逻辑。

/* config-overrides.js */

一个 webpack plugin 应该长这个样子:

1. 它是一个类,能够被实例化
2. 能够接收一些配置参数

const path = require("path");
const EmWebpackPlugin = require("error-monitor-webpack-plugin");

const pathResolve = p => path.join(process.cwd(), p);

module.exports = function override(config) {
  //do some stuff with the webpack config...
  config.plugins.push(
    new EmWebpackPlugin({
      url: "localhost:5000/sourcemap/upload", // 后端上传 source-map 接口
      outputPath: config.output.path // 打包 output 路径
    })
  );

  return config;
};
复制代码

下面让咱们来实现插件的内部逻辑。

const { uploadSourceMaps, readDir } = require("./utils");

/** * @param {插件配置桉树} options */
function errorMonitorWebpackPlugin(options = {}) {
  this.options = options;
}

// 插件必须实现一个 apply 方法,这个会在 webpack 打包时被调用
errorMonitorWebpackPlugin.prototype = {
  /** * @param {编译实例对象} compiler */
  apply(compiler) {
    const { url, outputPath } = this.options;
    /** * compiler hook: done * 在打包结束时执行 * 能够获取到访问文件信息的入口 * https://webpack.js.org/api/compiler-hooks/#done */
    if (url && outputPath) {
      compiler.hooks.done.tap("upload-sourcemap-plugin", status => {
        // 读入打包输出目录,提取 source-map 文件
        const sourceMapPaths = readDir(outputPath);
        sourceMapPaths.forEach(p =>
          uploadSourceMaps({
            url: `${url}?fileName=${p.replace(outputPath, "")}`,
            sourceMapFile: p
          })
        );
      });
    }
  }
};

module.exports = errorMonitorWebpackPlugin;

复制代码

过滤并提取 source-map 文件:

const p = require('path');
const fs = require('fs');
// 咱们仅取出 .map 文件和 manifest.json 文件
const sourceMapFileIncludes = [/\.map$/, /asset-manifest\.json/];

/** * 递归读取文件夹 * 输出source-map文件目录 */
readDir: path => {
const filesContent = [];

function readSingleFile(path) {
  const files = fs.readdirSync(path);
  files.forEach(filePath => {
    const wholeFilePath = p.resolve(path, filePath);
    const fileStat = fs.statSync(wholeFilePath);
    // 查看文件是目录仍是单文件
    if (fileStat.isDirectory()) {
      readSingleFile(wholeFilePath);
    }

    // 只筛选出manifest和map文件
    if (
      fileStat.isFile() &&
      sourceMapFileIncludes.some(r => r.test(filePath))
    ) {
      filesContent.push(wholeFilePath);
    }
  });
}

readSingleFile(path);

return filesContent;
}
复制代码

上传文件到服务器, 这里咱们选用 http 来完成文件上传,也能够选用其余的 RPC 框架来完成这一步。

const { request } = require("http");

const uploadSourceMaps = options => {
const { url, sourceMapFile } = options;
if (!url || !sourceMapFile)
  throw new Error("params 'url' and 'sourceMapFile' is required!!");

const [host, o] = url.split(":");
const i = o.indexOf("/");
const port = o.slice(0, i);
const path = o.slice(i);

const req = request({
  host,
  path,
  port,
  method: "POST",
  headers: {
    "Content-Type": "application/octet-strean",
    // 因为咱们的文件经过二进制流传输,因此须要保持长链接
    // 设置一下request header
    Connection: "keep-alive",
    "Transfer-Encoding": "chunked"
  }
});

fs.createReadStream(sourceMapFile)
  .on("data", chunk => {
    // 对request的写入,会将数据流写入到 request body
    req.write(chunk);
  })
  .on("end", () => {
    // 在文件读取完成后,须要调用req.end来发送请求
    req.end();
  });
},
复制代码

这样,咱们的 webpack 上传 source-map 的插件就完成了,下来处理一下服务端的逻辑。

本地调试 plugin

webpack plugin 实现以后,咱们怎么链接到前端项目里面进行使用和调试呢 ?

方法:

1. 经过相同路径引用

这种方式很直接,也不须要额外操做,可是调试效果比较差

2. npm link

npm 提供了用于开发 npm 模块时的调试方案

复制代码

首先在 webpack plugin 工程中添加 link

下来在前端项目中 link 咱们开发好的 webpack plugin

能够看到 node_modules 中已经有咱们的插件了。

而后在 webpack config 中引入,直接以绝对路径引入并使用

const EmWebpackPlugin = require("error-monitor-webpack-plugin");
复制代码

接收并解析 source-map

首先,咱们须要再后端新增一个路由,接收来自插件的请求

const router = require("koa-router")();
const sourcemapController = require("../controller/c-sourcemap");

// 上传sourcemap文件
router.post("/sourcemap/upload", sourcemapController.uploadSourceMap);

module.exports = router;
复制代码
const qs = require("querystring");
const path = require("path");
const { sourceMapConfig } = require("../config/default");
const { writeFile, delDir } = require("../utils/writeFile");

exports.uploadSourceMap = ctx => {
  ctx.req
    .on("data", data => {
      // 接收到的data会是一串二进制流
      // 咱们进行序列化
      const souremapContent = data.toString("utf8");
      const { querystring } = ctx.request;
      // 并从请求 url 中提取出 outputPath 参数
      const { fileName } = qs.parse(querystring);
      // 咱们将收集到的 source-map 以文件形式写入
      writeFile(path.join(sourceMapConfig.dir, fileName), souremapContent);
    })
    .on("close", () => {})
    .on("error", () => {})
    .on("end", () => {});
};
复制代码

存储 source-map

咱们将 source-map 以本来的目录层次存放在服务器中,这样方便后续的 source-map 解析

const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");

exports.writeFile = (fileName, content, options = {}) => {
  if (!content || !fileName) {
    throw new Error("'content', 'fileName' is required!!");
  }

  try {
    const { prefixDir = process.cwd() } = options;
    const pieces = fileName
      .replace(prefixDir, "")
      .split(/\//)
      .filter(p => !!p);
    let i = 0;
    if (pieces.length > 1) {
      let currentPath = prefixDir;
      // 自动建立空目录
      while (i < pieces.length - 1) {
        const checkedPath = path.resolve(currentPath, pieces[i]);
        if (!fs.existsSync(checkedPath)) {
          fs.mkdirSync(checkedPath);
        }
        currentPath = checkedPath;
        i++;
      }
    }
    fs.writeFile(fileName, content, e => {
      if (e) throw e;
    });
  } catch (e) {
    throw new Error("write file failed, beacuse of these:", e);
  }
};
复制代码

使用 map 文件

如今 map 文件存储好了,下来能够进行消费使用了。

如今考虑一个问题,咱们是在上报错误时解析仍是在前端获取错误列表时解析 ?

设想一下具体的场景,系统上报错误时是一个个上报,在获取列表时,是批量的获取。显而易见,咱们应该在上报时解析 source-map 并存储到数据库。

下来让咱们实现具体逻辑:

// 扩展刚才的uploadErrors
const uploadErrors = async ctx => {
  try {
    const body = ctx.request.body;
    const { stack } = body;
    // 解析 source-map
    const sourceInfo = findTheVeryFirstFileInErrorStack(stack);
    const sourceMapInfo = await soucemapParser(sourceInfo);
    // 将 source-map 信息插入表中
    await ctx.mysql.whriteError({ ...body, sourcemap: sourceMapInfo });
    ...
  } catch (e) {
    ...
  }
};
复制代码

最开始,咱们提到了,不少时候咱们上报的错误信息,没有行列号和错误文件名。这个时候,咱们能够选择从错误栈信息中提取。

咱们以错误栈顶第一个文件为目标,由于这个文件通常就是咱们真正编码的文件,对此进行错误文件和行列号提取。获得三个核心参数中,再对其进行解析。

source-map 解析

解析 source-map 的工做,咱们选择使用 source-map 这个 sdk 来完成, 这是 Mozilla 提供的一个 node 模块。

固然若是你们有兴趣,能够自行实现一下 source-map 解析,会对这一块有更深刻的认识,23333...

const fs = require("fs");
const path = require("path");
const sourceMapTool = require("source-map");
const { sourceMapConfig } = require("../config/default");

// 检验是否为文件夹
const notStrictlyIsDir = p => !/\./.test(p);

// 检测manifest文件
const isManifest = p => /manifest\.json/.test(p);

// 从sourcemap目录中找到sourcemap文件
const findManifest = baseDir => {
  const files = fs.readdirSync(baseDir);

  if (files.some(f => isManifest(f))) {
    return path.join(baseDir, files.filter(f => isManifest(f))[0]);
  }

  files.forEach(f => {
    if (notStrictlyIsDir(f)) {
      findManifest(path.join(baseDir, f));
    }
  });
};

/** * * @param {sourcemap 文件} sourcemapFile * @param {行号} line * @param {列号} col * * 经过 sourec-map 来解析错误源码 */
const parseJSError = (sourcemapFile, line, col) => {
  // 选择抛出一个 promise 方便咱们使用 async 语法
  return new Promise(resolve => {
    fs.readFile(sourcemapFile, "utf8", function readContent( err, sourcemapcontent ) {
      // SourceMapConsumer.with 是该模块提供的消费 source-map 的一种方式
      sourceMapTool.SourceMapConsumer.with(sourcemapcontent, null, consumer => {
        const parseData = consumer.originalPositionFor({
          line: parseInt(line),
          column: parseInt(col)
        });

        resolve(JSON.stringify(parseData));
      });
    });
  });
};

/** * 根据 sourcemap 文件解析错误源码 * 1. 根据传入的错误信息肯定sourcemap文件 * 2. 根据错误行列信息转换错误源码 * 3. 将转换后的错误源码片断入库 */

module.exports = (info = []) => {
  const [filename, line, col] = info;

  // 错误文件的 map 文件
  const sourcemapFileName = `${sourceMapConfig.dir}${filename}.map`;

  if (fs.existsSync(sourcemapFileName)) {
    return parseJSError(sourcemapFileName, line, col);
  }

  return Promise.resolve(JSON.stringify({}));
};

复制代码

经过 source-map consumer 解析出的会是一个 js 对象,包含了 source, line, column, name 几个信息,其中包含了源文件名,解析后的行号和列号。

这样咱们就获得了错误的原始文件及错误位置。

{ 
  source: 'http://example.com/www/js/two.js',
  line: 2,
  column: 10,
  name: 'n' 
}
复制代码

咱们选择将这个对象进行序列化,直接存储到数据库字段中,以后在前端进行展现。

能够看到接口中的 sourcemap 字段就是咱们最终解析到的错误源文件信息。

最后再看一下咱们的前端效果。

到这里,咱们就彻底还原了错误发生现场,能够愉快的进行问题回溯了。

Congratulations ~~~~

本地搭建生产环境

在开发工程中,遇到了一个问题,就是在分析 source-map 时,发现须要的 map 文件找不到。最后发现,是由于咱们从 dev 模式生产的错误,可是 map 文件是 production 模式打出来的包。

Are you kidding me ?
复制代码

因而咱们颇有必要搞一套生产环境出来,来模拟整个线上流程。

可是我只有一台机器,怎么办?如今已经跑了一个前端,一个后端,一个数据库,一个 webpack plugin。

不用慌,容器化帮你解决微服务,Docker献上...

Dokcer

在此以前,其实已经有使用过一段时间的 docker 和 K8s 了,咱们本身的产品自己也是容器化自动化部署。它能够极快极其方便的帮忙你起一个应用。

在一线大厂,容器化设施也是很是完备,许许多多互联网产品都在其上运行。使用它那你能够快速的建立一个 Ubuntu OS 运行环境,一个 Nginx 服务器,一个 mysql 数据库,一个 Redis 存储器。因此,若是你还不知道的话,还不快来了解一下。(我真的不是安利...)

在这里,咱们选用 Nginx 做为前端服务器,负载均衡,高性能的HTTP和反向代理web服务器。相信大家本身的产品大多数也运行在 Nginx 服务器中。

1. 首先咱们须要安装 [docker](https://www.docker.com/)

2. 下来拉取 nginx 镜像。

   docker pull nginx

3. 建立 nginx 相关目录

mkdir -p /data/nginx/{conf, conf.d,logs}

这里咱们在宿主机的 /data/nginx 目录放置 nginx 相关的文件,这个目录是可自定义的,但后续的目录映射必定要保证和这个目录相同。

4. 新建 nginx 配置文件

   touch /data/nginx/conf/nginx.conf
   vim /data/nginx/conf/nginx.conf

   ```conf user nginx; worker_processes 1; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; access_log /var/log/nginx/access.log main; sendfile on; #tcp_nopush on; keepalive_timeout 65; #gzip on; include /etc/nginx/conf.d/*.conf; } ``` 5. 新建 default.conf touch /data/nginx/conf.d/default.conf vim /data/nginx/conf.d/default.conf ```conf server { listen 80; server_name localhost; location / { root /usr/share/nginx/html; index index.html index.htm; autoindex on; } error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; } ``` 到这里,有关于 nginx 配置的处理就完成了,下来咱们要作的就是进行 docker 容器与宿主机的目录映射 6. 将 nginx 内容挂载到宿主机 docker run -p 80:80 -d -v /Users/xxx/Documents/lab/error-monitor/react-repo/build:/usr/share/nginx/html -v /data/nginx/logs:/var/log/nginx -v /data/nginx/conf/nginx.conf:/etc/nginx/nginx.conf -v /data/nginx/conf.d:/etc/nginx/conf.d docker.io/nginx 这里能够看到咱们映射了两个目录和两个配置文件,包括了前端 html 文件目录,log 目录以及两个 nginx 配置文件。这里我直接将咱们前端项目的打包目录映射到了容器中的 html 目录中,这样会比较方便一些。 这里咱们选择宿主机的 80 端口映射 nginx 容器的 80 端口,咱们直接打开本机的浏览器访问 localhost ,就能够看到打包完后的前端项目运行起来了。若是 80 端口有其余用途 ,能够自行切换到其余端口。 复制代码

总结

到这里,咱们的前端监控系统基本完成了。

给本身鼓个掌~~~

别急,其实目前这个版本还只是初版,初步走通了整个流程。

其中还有一些须要完善的地方,会在后续进行补充。在使用场景上,也还须要进一步的进行测试。

提早贴出来,但愿一可以先阶段性回顾总结一下整个项目,由于整个系统仍是比较复杂的,二来但愿可以给你们分享一些可能也许有一丁点儿用处的内容。

文章中其实只贴了很小一部份内容,你们若是有兴趣能够进一步了解,其中 README 有写一些知识点和构建思路以及学习经验。

仓库代码

前端: error-monitor-frontend

后端:error-monitor-node-server

source-map插件: error-monitor-webpack-plugin

延续

细心的同窗可能发现,文章头部贴的架构图和文章内容有点出入,还有咱们标题有(上)的标注。

因此,后续在对这个系统近一步完善后,应该会有 (下)片补上,欢迎指正!!!欢迎点赞关注!!!(不是搞直播的)👏👏👏👏

相关文章
相关标签/搜索