自研搭建前端监控平台🔥

4,527 阅读9分钟

为什么要自研:相比大家都有自己的答案,我只想变💪。
前端监控平台由三大部分组成收集信息的SDK、处理上报数据的 Service、展示数据的界面 自研流程调研分析=> 梳理核心难点 => 规划开发计划 => 开干💪

收集那些信息?

  1. 用户行为:用于收集用户在网站或应用中的操作信息,例如点击、滚动、输入等。
  2. 加载性能:用于收集页面加载和渲染的相关性能指标,例如页面加载时间、白屏时间等。
  3. 异常报错:用于捕获并上报前端代码运行过程中出现的异常和错误。
  4. 资源加载情况:用于监控页面中各种资源(例如图片、脚本、样式表等)的加载情况,以及它们对页面性能的影响。
  5. 其他监控:接口数据、导航栈信息、设备信息上报、控制台打印、插件运行情况等。

分析各大平台sourcemap异常定位实现方案

知己知彼,方有自己的一条路

Fundebug

   默认情况下,Fundebug会根据压缩代码中的sourceMappingURL下载Source Map文件,用户仅需要将生成的Source Map文件部署在服务器上,不需要额外操作

   如果用户不希望公开Source Map,则可以主动上传Source Map文件。Fundebug提供了两种不同的上传方式:

  • 通过Fundebug的前端UI上传
  • 通过Fundebug的API上传

Webfunny

公司成立于2021年05月25日,我们致力于帮助前端工程师定位并解决各种线上问题,确保项目健康良好的运行。提供异常数据及性能数据的查询,及提供用户行为记录数据,个人免费版本只有2周,目前对部分小程序还未支持。

  • 优点:提供各种大屏可视化数据展示
  • 缺点:公司级产品需付费,平台支持还不够完善

Webfunny定位问题:

  1. 开启minimize、source-map打包后部署到生产环境。
  2. 部署上产以后,在关闭minimize代码混淆压缩,再次打包,保存这次打包后的代码
  3. 遇到问题使用生产环境定位的位置信息,在本地进行源码定位

frontjs

   frontjs是蒲公英旗下一款多个维度监测网站的产品,除开对异常事件的监控,还增加了性能,访问数据,留存,日报等功能。但默认的基础版本无用户范围和性能监控,且数据保留24小时,高级版和私有化部署都需要额外收费。

  • 优点:异常和性能监控都区分了类型,网络请求和资源下载等;除开对异常事件的监控,还增加了性能数据等监控,功能较丰富。
  • 缺点:基础版本不够用,升级需付费,且异常监控无行为记录,错误记录较为表面。

sentry

   sentry是一个开源的监控系统能支持服务端与客户端的监控,还有个强大的后台错误分析、报警平台。主要用于如何快速的发现故障。支持几乎所有主流开发语言和平台,并提供了现代化UI,它专门用于监视错误和提取执行适当的事后操作所需的所有信息,官方提供了多个语言的SDK。让开发者第一时间获悉错误信息,并方便的整合进自己和团队的工作流中。

  • 优点:支持语言全面,功能较完善,开源免费/收费使用saas服务
  • 缺点:外国服务器,需要考虑服务稳定性

sentry定位问题:

大致有三种方式:

  1. 官方cli(sentry-cli)
  2. 调用官方提供的API(HTTP)接口上报
  3. webpack插件进行上报

当下产品需求

  • 无感知收集:资源加载错误、js执行报错、接口异常...等

  • 开发SDK,并上传npm私服

  • 新建监控平台,支持错误筛选,定位,指派,解决。

SDK开发流程

定义上报数据类型 -> 采集数据-> 信息上报

过程自述

  1. 使用npm安装SDK,初始化SDK配置,打包部署前端项目,运行前端项目。
  2. SDK无感知采集数据,数据批量有序上报。后端接收数据存储到日志服务中,在对数据进行分析、打标签等。
  3. 打开监控平台查看终端使用情况,数据展示是否正确。点击详情解析错误信息,并展示。

sourcemap文件部署在哪里?

  1. 将源码sourcemap与文件一同部署:使用加密脚本混淆sourcemap文件内容,限制内网ip访问
  2. 通过打插件将sourcemap文件上传到私服:开发 vue-cli/vite 插件, 打包完成上传文件到云服务器保存

线上程序报错后,通过接口api将错误信息上报到后端服务。后端通过错误信息位置与获取到的sourcemap文件进行调用skd进行反向解析出源代码位置及代码片段,返回json数据给客户端进行展示。

graph TD
Start --> 安装SDK --> 打包上传源文件 --> sourcemap
sourcemap --> 部署到服务器通过外网下载访问 --> SDK上报错误
sourcemap --> 打包时上传到服务器 --> SDK上报错误
服务端 --> 保存sourcemap文件 --> 接收并保存报错信息  --> 调用source-map模块进行解析错误具体位置 --> 读取目标源文件 --> 截取代码片段并保存 --> 返回前端展示
前端 --> 检索错误信息 --> 展示错误详情

错误收集

第一个版本先集成到微信小程序中,也就是捕获js的异常问题.

我们常见的异常有:...(如下所示)...那么我们怎么捕获这些异常呢?

  • 静态资源加载类型异常
  • js 代码执行时异常
  • promise 类异常
  • 接口请求类型异常
  • 跨域脚本执行异常
  • log控制台error

web端数据采集

window.onerror

window.onerror是JS原生支持的错误捕获api。但是这个方法存在兼容性问题,在不同的浏览器上提供的数据不完全一致,部分过时的浏览器只能提供部分数据。有时候window.onerror拿不到详细的错误信息;

  • 能捕获
    • js 代码执行时异常
    • 异步代码执行错误(setTimeout)
  • 不能
    • 被其他程序提前捕获的错误
    • 跨域的JS资源
    • 语法错误
    • 资源错误,不能捕获

   window.onerror其实是一个回调函数,也就是发生异常的时候这个javaScript会执行window上的属性onerror,onerror是一个函数,默认如下: window.onerror = function (message, url, lineNo, columnNo, error)
参数的含义:

  • message {String} 错误信息。直观的错误描述信息,不过有时候你确实无法从这里面看出端倪,特别是压缩后脚本的报错信息,可能让你更加疑惑。
  • url{String} 发生错误对应的脚本路径,比如是你的a.js -报错了还是- b.js报错了。
  • lineNo{Number} 错误发生的行号:就是在url文件中的哪一行,如果代码混淆压缩过也是无法很好的阅读
  • columnNo {Number} 错误发生的列号。
  • error {Object} 具体的 error 对象,包含更加详细的错误调用堆栈信息,这对于定位错误非常有帮助。
对于window.onerror的补充

window.addEventListener('error', (error) => {}) 可以捕获:图片、script、css加载错误

window.addEventListener('error', (error) => { console.log('捕获到异常:', error); }, true)
unhandledrejection

unhandledrejection捕获Promise类型错误

errorHandler : vue实例挂载错误回调事件
  • 能捕获
    • errorHandler只能拦截vue程序中抛出的且没有被捕获的错误;
  • 不能捕获
    • ssr模式下无法捕获。

初始化:

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

小程序数据采集

  1. wx.api拦截器

  2. uni.onError

    // app.vue
    export default {
      onError: function (err,b){
          console.log('App onError',err,'---',b)
       },
     }
    
  3. 小程序报错文件定位时,需要找到对应的app-service.map.js文件路径,来定位源码位置。

    • 通过重写wx的navigateTo方法来获取页面跳转路由path
    • 在通过app.json文件路由结构来确定报错文件app-service.map.js所属的目录
  4. 解决小程序报错信息中的文件行号和列号的提取

      const stack = (stack) => {
      const lines = stack.split("\n");
      // 报错信息
      const newLines = [lines[0]];
      // 逐行处理
      for (const item of lines) {
        if (/.*(https?:\/\/.+):(\d+):(\d+)$/) {
          const arr = item.match(/@(https:\/\/.+):(\d+):(\d+)$/) || [];
          if (arr.length === 4) {
            const url = arr[1];
            const line = Number(arr[2]);
            const column = Number(arr[3]);
            const filename = (url.match(/[^/]+$/) || [""])[0];
            // console.log("url: ", url);
            // console.log("line: ", line);
            // console.log("column: ", column);
            // console.log("filename: ", filename);
            // console.log("---------------: ");
            newLines.push({
              url,
              line,
              column,
              filename,
            });
          }
        }
      }
      return newLines.join("\n");
    };
    
    const results = stack(
      `<TypeError: this.initData is not a function. (In 'this.initData()', 'this.initData' is undefined)>\nonShow@https://usr//app-service.js:5744:2010\nonShow@[native code]\npo@https://usr//app-service.js:5057:27522\ngo@https://usr//app-service.js:5057:27601\n@https://usr//app-service.js:5057:32581\n@https://usr//app-service.js:5057:54694\n@https://lib/WASubContext.js:1:775540\n@[native code]\n@https://lib/WASubContext.js:1:775333\n@https://lib/WASubContext.js:1:785609\n@https://lib/WASubContext.js:1:745394\n@https://lib/WASubContext.js:1:788116\n@https://lib/WASubContext.js:1:745394\n@https://lib/WASubContext.js:1:791446\n@https://lib/WASubContext.js:1:745394\nxr@https://lib/WASubContext.js:1:792142\n@https://lib/WASubContext.js:1:744498\n@https://lib/WAServiceMainContext.js:1:847062\nemit@https://lib/WAServiceMainContext.js:1:843813\nemit@[native code]\n@https://lib/WAServiceMainContext.js:1:2585312\n@https://lib/WAServiceMainContext.js:1:853487\n@https://lib/WAServiceMainContext.js:1:847161\nemit@https://lib/WAServiceMainContext.js:1:843813\n@https://lib/WAServiceMainContext.js:1:907321\n@https://lib/WAServiceMainContext.js:1:884248\nemit@https://lib/WAServiceMainContext.js:1:94817\nemit@[native code]\nemit@https://lib/WAServiceMainContext.js:1:94436\nsubscribeHandler@https://lib/WAServiceMainContext.js:1:97085\nglobal code@`
    );
    
    console.log("解析结果:", results);
    

错误类型

  • 资源报错
    • MediaLoadError:图片,视频等静态资源加载失败
  • 代码JS报错
    • Uncaught Error: 错误基类
    • SyntaxError:语法写错了
    • ReferenceError: 引用错误
    • RangeError:使用内置对象的方法时,参数超范围
    • URIError:URI错误
    • TypeError:错误的使用了类型或对象的方法时
    • EvalError: Eval错误(TypeErroreval函数没有被正确执行时,会抛出EvalError错误。)
    • Unexpected 不符合语法规范
    • unhandledrejection: Promise的reject报错
  • 框架报错
    • VueError: vue前端框架报错
  • 接口报错
    • RequestConnectionError: 接口连接服务报错
    • RequestCodeError: 接口异常Code报错
  • 未知错误
    • Unknown = 'unknownError' // 未知错误

vue-cli 上传sourceMap插件

两种使用方式: 一种调试,一种打包自动上传
源码如下⬇️上传服务地址自行更改或者通过插件入参传递

源码

1. 可以手动触发调试: yarn uploadSourceMap

// package.json
{
  "name": "wqd-xxx",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    ...
    "uploadSourceMap": "node ./public/uploadSourceMapPlugin.js run:true",
  },
  ...
}

2. vue-cli引起插件

// vue.config.js
const UploadSourceMapPlugin = require("./public/uploadSourceMapPlugin");
module.exports = defineConfig({
  // 生产环境是否生成 sourceMap 文件,默认 true
  productionSourceMap: true,
  transpileDependencies: true,
  configureWebpack: (config) => {
    ...
    const plugins = [];
    if (IS_PROD) {
      plugins.push(
        new UploadSourceMapPlugin()
      );
    }
    config.plugins = [...config.plugins, ...plugins];
    ...
  },
  ...
}

服务端

使用的是 egg.js 框架
这里只提供基本的数据存储解析错误代码片段 以下片段分为两个部分,一个是基类,一个是 controller

代码片段

遗留

  1. uni-app小程序错误定位,详细流程,抽时间再写
  2. 数据采集 SDK,可以参考mitojs
  3. 前端监控界面搭建-不在提供

总结

本文纯属自己个人观点,如有误解请评论指出,一同进步🙏🏻

参考文献