搭建异常监控系统 流程
收集异常报错
上报异常信息
构建时将sourcemap
文件上传至服务器
报错时服务器接收错误并记录到日志中
根据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 ? error.stack || msg, row, col, url, time : Date .now () } Axios .post ({ 'https://xxxx' , errorMsg }) }
资源加载异常 可以使用 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 , }; } }, 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 }) }) 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 ) => { };
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 ) { } }
但是errorboundaries
并不会捕捉这些错误
事件处理程序
异步代码
服务端的渲染代码
error boundaries
自己抛出的错误
可以使用 react-error-catch
这个库来对上面的错误捕获
页面崩溃和卡顿
使用 load
和 beforeunload
来对网页奔溃进行监控
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" ) { 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 = function (...args ) { let currentHref = location.href ; self._captureUrlChange (self._lastHref , currentHref); if (oldOnPopState) { return oldOnPopState.apply (this , args); } };
控制台行为 通过改写 console
对象的 info | warn | error
在console
执行时将信息收集
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 中new
出Image
对象就可以发起请求 而且没有阻塞问题 在不支持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
:错误信息(错误栈 定义错误最核心的信息)
上报数据序列化 使用字符串形式传送异常信息 所以需要对对象进行序列化处理(后端接收到后再做相应的反序列化处理)
将异常信息从属性中解构出来存入一个 JSON 对象
将 JSON 字符串转换为字符串
将字符串转换为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 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; }
设置阈值:为了在大量爆发错误的时候避免服务器过载
数据预处理 对于获取到的数据 应该对数据信息进行适当的提取 只将有需要的数据提取出来 并且还要去除原始数据中的无用信息 减少存储体积
数据聚合 聚合的目的:
存储性能:存储小
查询性能:查询快
聚合的维度:业务、错误类型、错误信息
最基础的聚合方式就是对上报的全部内容进行一个散列 求 MD5 的值 然后将所有散列值相同的错误聚合成同一类错误(简单粗暴 但是聚合度很低 很多明明是同一种错误被聚合成不同的错误)
数据持久化 在错误大量发生时进行削锋处理 避免监控系统在大量错误爆发时挂掉
例如可以在每次轮询时从ES
中拉取最新的 10000 条错误日志 同时聚合过后 同一类型的错误只取部分数量(例如 300 条)写入具体的事件数据库 其他的可以将其增加改类型的错误发生数量即可(这样子可以保证我们处理的永远都是最新的错误 既能避免大量的数据读写 也能让数据处理任务更快完成)
辅助错误分析 针对前端 我们记录了前端用户在页面上发的 ajax 请求、点击事件、跳转事件以及控制台请求,当发生错误时 将这些行为日志和错误日志关联起来 就可以更快地判断用户是在那些操作的时候发生的错误
当我们在进行问题分析时 仅依靠日志上报的信息 很多时候是不够的 因为缺少了了用户浏览路劲以及操作行为等信息 而客户端错误很多都是特定前景下发生的
可以给客户端建立一套日志链路 从客户端的一次冷启动开始 就生成一个链路 id 后续的所有行为日志 网络日志和崩溃日志都会带上这个 id 帮我们记录一些关键节点(页面跳转、网络环境变化、错误的网络请求、用户操作行为等)这样就可以通过这些id
直接关联到相关的行为日志 方便后续的错误排查
异常收集 用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 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 ) } 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' ) 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.customLogger = { frontentLogger : { file : path.join (appInfo.root , 'logs/fronted.log' } } 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 = '' ; }
异常分析 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) => { 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 import UploadSourceMapWebpackPlugin form './plugin/uploadSourceMapWebpakcPlugin' plugins : [ new UploadSourceMapWebpackPlugin ({ uploadUrl : 'https://olddog/monitor/sourcemap' , apiKey : 'olddog' }) ]
这样子执行 webpack 打包时调用插件sourcemap
将被上传至服务器
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 (); async function uploadDel ( ) { const list = glob.sync (path.join ('./dist' , './**/*.{js.map}' )); 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 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` , uploadUrl : `${env.VITE_MONITOR_UPLOAD_API} /uploadSourceMap` , }), ], build : { 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 import handleError from './utils/monitor' ;const app = createApp (App );handleError (app, import .meta .env .VITE_MOBNITOR_REPORT_API );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; if (regIE.test (agent)) { return agent.match (regIE)[0 ]; } if (regIE11.test (agent)) { return "IE11" ; } if (regFireFox.test (agent)) { return agent.match (regFireFox)[0 ]; } if (regQQ.test (agent)) { return agent.match (regQQ)[0 ]; } if (regEdg.test (agent)) { return agent.match (regEdg)[0 ]; } if (regChrome.test (agent)) { return agent.match (regChrome)[0 ]; } 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 (), 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' ; } 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>` ; }); 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); 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 , 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 ) } 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 ) { 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" , }); });