Skip to content
微信公众号

错误监控

错误采集

代码异常监控的目的是发现已经存在的代码隐患,根据报错信息对线上问题进行定位和修复。如果开发人员要发现线上错误,那么首先需要对线上发生的错误进行采集。

JavaScript提供了try catch函数用于捕获函数异常,被捕获的错误不会影响后续代码的执行,但是需要依赖开发人员手动对可能出现异常的代码块进行包裹。

js
try{
    document.getElementById('main'.innterText) = 'test';
}catch(error){
    console.error(error);//TypeError:Cannot set properties of null (setting 'innerText')
}

try catch函数只能捕获当前执行的上下文中抛出的错误。如果被包裹的代码块脱离了当前执行的上下文,则try catch函数没有办法捕获错误。

js
try{
    setTimeout(()=>{
        document.getElementById('main').innerText = 'test';
    },1000)
}catch(error){
    console.error('捕获成功');//不会被执行
}

在实际开发中,开发人员只会针对一些特殊的案例使用try catch函数。如果大量使用try catch函数,那么不仅会增加开发人员的负担,也会降低代码的可读性,因此需要一种对工程低侵入的错误采集方案。如果需要进行全局JavaScript异常监控,就需要用到window.onerror和window.addEventListener('error')。

window.onerror是一个全局变量,默认值为null,接受一个函数用于处理错误,可以通过自定义全局的error事件处理函数来自动收集错误信息。

js
window.onerror = (message,source,lineno,colno,error)=>{
    console.log('报错信息:',message);
    console.log('错误文件地址:',source);
    console.log('错误文件行数:',lineno);
    console.log('错误文件列数:',colno);
    console.log('错武器Error对象的堆栈:',error);
    //返回true,阻止执行默认事件处理函数,控制台不会再默认打印错误信息
    return true;
}
function throwError(){
    throw new Error('错误');
}
throwError();

若该函数返回true,则阻止执行默认事件处理函数,控制台不会再默认打印错误信息。当JavaScript执行发生错误时,window会触发一个ErrorEvent接口的error事件。如果此时window.onerror不为null,就会执行window.onerror。

window.onerror的缺陷在于它没有办法捕获资源加载的错误,以img标签为例。

html
<img src="./404.png" onerror="console.log('错误')" />

404.png是一个不存在的资源,当浏览器加载时会报错,加载资源的元素标签会触发一个Event接口的error事件,该事件不会在window上“冒泡”,所以此时window.onerror无法捕获到该事件,会默认执行img标签上的onerror函数。

window.addEventListener('error')具备window.onerror的多数功能,但它不能通过返回true的形式来阻止默认事件处理函数的执行,可以通过调用e.preventDefault()方法来阻止默认行为。在错误捕获的时机上,它比onerror更早被触发。

js
window.addEventListener('error',(e)=>{
    const { message, filename,lineno, colno,error} = e;
    console.log('报错信息:',message);
    console.log('错误文件地址:',filename);
    console.log('错误文件行数:',lineno);
    console.log('错误文件列数:',colno);
    console.log('错武器Error对象的堆栈:',error);
});

虽然资源加载的事件不会在window上“冒泡”,但是开发人员可以在事件捕获阶段采集错误,将addEventListener的第三个参数设置为true,即可在事件捕获阶段采集到错误,从而实现对资源加载错误的监听。

html
<img src="./404.png" />
<script>
    window.addEventListener('error',(e)=>{
        const { target } = e;
        console.log('资源加载失败标签:',target.nodeName);
        console.log('资源地址:',target.src);
    },true);
</script>

开发人员在使用window.addEventListener('error')采集错误时,需要区分JavaScript错误和资源加载错误,可以利用lineno和colno字段实现。当资源加载失败时,回调参数中的两个字段的值为undefined。同时,也可以利用instanceof来判断,JavaScript错误抛出的事件类型为ErrorEvent,资源加载失败抛出的事件类型为Event,ErrorEvent为Event的子类型。优化后的代码如下。

html
<img src="./404.png" />
<script>
    window.addEventListener('error',(e)=>{
        if(e instanceof ErrorEvent){
            const { message, filename,lineno, colno,error} = e;
            console.log('报错信息:',message);
            console.log('错误文件地址:',filename);
            console.log('错误文件行数:',lineno);
            console.log('错误文件列数:',colno);
            console.log('错武器Error对象的堆栈:',error);
        }else{
            const { target } = e;
            console.log('资源加载失败标签:',target.nodeName);
            console.log('资源地址:',target.src);
        }
    },true);
</script>

JavaScript代码中还存在异步错误,这种错误没办法被上述三种方法捕获,需要使用addEventListener监听unhandledrejection。

js
new Promise((resolve,reject)=>{
    referenceError
});
window.addEventListener('unhandledrejection',(e)=>{
    console.log('错误信息:',e.reason.message);
    console.log('错误堆栈:',e.reason.stack);
},true);

通过unhandledrejection事件采集到的错误信息不包括抛出错误的文件地址、代码行列,这些信息全部存储在e.reason.stack中。

js
 ReferenceError: referenceError is not defined
    at parseStack (file:///C:/Users/18307/Desktop/index.html:15:13)
    at new Promise (<anonymous>)
    at file:///C:/Users/18307/Desktop/index.html:14:9

以上述错误堆栈为例,开发人员可以通过正则表达式将结果提取出来,从而得到相应的信息,下面是一个简单的示例。

js
const parseStack = (stack)=>{
    const results = stack.split('\n');
    const topFile = results[1].trim().split(' ')[2];
    const regResults = topFile.match(/\((.*)?\:(\d+)\:(\d+)\)$/);
    const [,filename,lineno,colno] = regResults;
    return {filename,lineno:+lineno,colno:+colno};
}
window.addEventListener('unhandledrejection',(e)=>{
    console.log('报错信息:',e.reason.message);
    const {filename,lineno,colno} = parseStack(e.reason.stack);
    console.log('错误文件地址:',filename);
    console.log('错误文件行数:',lineno);
    console.log('错误文件列数:',colno);
    console.log('错误堆栈:',e.reason.stack);

});

部分代码中的JavaScript异常会使用try catch函数进行处理,然后通过console.error打印。由于错误被catch捕获了,所以不会被全局监听的方法捕获。针对这种情况,开发人员可以通过劫持console.error的方式来采集打印的错误信息。

js
const _consoleError = window.console.error;
window.console.error = (...args)=>{
    //自定义内容部分
    _consoleError.apply(window.console,args);//执行原方法,保证劫持前后运行逻辑一致
}

通过以上措施,开发人员基本能够采集到JavaScript中的错误。在实际开发中,静态资源文件常常被托管到CDN服务器上,而在一般情况下,CDN域名和网站域名是不一致的,出于安全性考虑,浏览器只允许同域的脚本捕获具体错误信息,会对抛出的错误进行脱敏处理,以防止敏感信息泄露,此时捕获错误的JavaScript代码就无法有效获取错误信息。解决以上问题的方法是为跨域脚本添加crossorigin="anonymous"配置,同时CDN服务器需要为HTTP响应报头Access-Control-Allow-Origin配置合理的值,此时就能正常地捕获JavaScript错误。

错误处理

错误可以分为3类:

  • JS错误
  • Promise异常
  • 资源异常

为了方便后续错误的监控、排查定位等工作,开发人员应该为每种错误类型都定义一个固定的数据结构。同步错误和异步错误都属于JavaScript逻辑错误,它们关注的信息在于错误的文件地址和错误堆栈,所以应该具有相同的数据结构。资源加载错误和JavaScript逻辑无关,常常是文件地址错误或者网络状况不佳等原因导致的,所以只需要关注资源的加载标签和地址。两者类型定义如下:

ts
/**JavaScript逻辑错误(包括同步错误、异步错误) */
type JsErrorDetail = {
  type: ErrorType.Error | ErrorType.Promise;
  msg: string; //错误信息
  resourceUrl: string; //资源文件地址
  lineNo: number; //错误行数
  colNo: number; //错误列数
  stack: string; //错误堆栈
}
/**资源错误 */
type ResourceErrorDetail = {
  type: ErrorType.Resource: //错误类型
  tag: string; //记载出错的资源标签,例如img、script和link等
  resourceUrl: string; //资源地址
}

接着需要定义响应的错误处理函数,对它们进行解析。在解析同步错误和异步错误时,都需要考虑抛出的错误未被Error对象包裹的情况,例如,

js
try{
  throw 1;
}catch(error){
  console.log(error.stack);  //undefined
  console.log(error); //undefined
}

同步错误能够直接从对象中获取错误的文件地址、代码行列位置,但是需要判断stack是否为Error对象。同步错误的解析函数如下。

js
const parseNormalError = (e)=>{
  const { message, filename, lineno,colno,error} = e;
  const detail = {
    msg: message,
    resourceUrl: filename,
    lineNo: lineno,
    colNo: colno,
    //抛出的错误为Error对象时,可以读取stack信息,否则直接转字符串
    stack: error instanceof Error? error.stack: JSON.stringify(error)
  }
  return detail;
}

开发人员在处理异步错误时,需要先判断e.reason是否为Error对象。如果不是Error对象,就直接将其字符串化。如果是Error对象,那么开发人员可以通过e.reason.message读取错误信息,诸如错误的文件地址、代码行列位置都需要根据stack信息进行解析。parseStack函数的具体实现可以参照上文,异步错误的解析函数如下。

js
const parseUnhandlerejection = (e)=>{
  const detail = {
    msg: '',
    resourceUrl: '',
    lineNo: -1,
    colNo: -1,
    stack: '',
  };
  if(e.reason instanceof Error){
    const { filename,lineno,colno } = parseStack(e.reason.stack);//从堆栈中解析关键信息
    detail.msg = e.reason.message;
    detail.resourceUrl = filename;
    detail.lineNo = lineno;
    detail.colNo = colno;
    detail.stack = e.reason.stack;
  }else{
    //e.reason可能为非Error对象,此时直接转字符串
    detail.stack = JSON.stringify(e.reason);
  }
  return detail;
}

当开发人员处理资源加载错误时,需要知道加载该资源的标签类型及资源地址。标签类型可以通过target.nodeName获取,但是在获取资源地址时需要经过特殊处理。因为img、video、script等标签是通过src设置资源地址的,link标签则是通过href设置的。除此之外,也可以将标签的其他属性上报,以便排查问题。资源加载错误的解析函数如下。

js
/**获取dom上所有的属性 */
const getAllAttrs = (dom)=>{
  const attrs = {};
  for(let i = 0;;i++){
    const temp = dom.attributes[i];
    if(!temp){
      return attrs;
    }
    attrs[temp.name] = temp.value;
  }
}
/**解析资源加载错误 */
const parseResourceError = (e)=>{
  const {target} = e;
  const detail = {
    tag: target.nodeName,
    resourceUrl: target.src || target.href,
    attrs: getAllAttrs(target)
  }
  return details;
}

将以上函数封装成一个函数,然后根据错误类型分配对应的处理函数即可。

js
const parseError=(e)=>{
  if(e instanceof ErrorEvent){ //处理同步错误
    return parseNormalError(e);
  }else if(e instanceof PromiseRejectionEvent){//处理异步错误
    return parseUnhandledrejection(e);
  }else if(e instanceof Event){ //处理资源加载错误
    return pareseResourceError(e);
  }
}

Script error

出于安全原因,浏览器有意隐藏来自不同来源的脚本文件的错误,这是为了比较脚本在无意中将潜在的敏感信息泄露出去,像错误信息、错误堆栈、错误的文件名、行列号都有可能包含敏感信息。出于这个原因,浏览器只监听同源的错误,对于非同源的错误,只能获取一个Script error.的信息,并且没有堆栈,所以监听者只能知道发生了一个错误,但是不知道发生了什么错误。

那么如何去解决呢?

  1. 为script添加crossorigin="anonymous"
html
<script src="http://xxxxxxx" crossorigin="anonymous"></script>
  1. 添加跨域头,将浏览器指示任何来源都可以获取这个页面,或者设置为允许访问的域名,CDN一般都支持这个头的设置。
html
access-control-allow-origin: *

执行时机

由于我们是通过监听的方式去捕获全局的JS错误,那么执行的时机就需要额外的注意,监听只会监听到代码执行后发生的JS错误,这就意味着监听的时机尽可能早,否则如果监听的代码执行较晚,比如错误已经发生了,但是监听代码还没有执行,那么这个错误就会被漏掉,那么怎么解决这个问题呢?这里我们可以使用预收集脚本,提前收集JS错误,

在预收集脚本中通过window.addEventListener('error',handler)来做收集,监听handler是有副作用的,后续完整的监控代码执行后就会把handler给清除掉,或者可以直接复用这个监听,这样就做到了监听尽早执行。

错误排查

开发人员在完成监控的错误采集和处理工作,并对收集到的JavaScript错误信息进行分析后,发现很难进行问题定位。

js
Uncaught TypeError: Cannot read properties of undefined (reading '2')
    at Module../b.js (<anonymous>:11:132878)
    at e (<anonymous:1:110>)
    at <anonymous>:1:902
    at <anonymous>:1:918

错误信息显示出错的代码位于第11行的第132878列,不仅代码列数十分夸张,变量的名称也可能是混淆的,这是因为生产环境中的代码通常是经过压缩的。

基于对减少请求次数、缩减代码的体积、防止代码泄露及兼容性处理等因素的考量,生产环境的JavaScript文件经过了文件合并、代码压缩及polyfill操作。代码在打包时会删除无意义的换行符和空格,并对变量名进行混淆替换,仅通过上述报错信息,开发人员很难排查错误逻辑。以上问题可以通过Source Map解决。

Source Map是一个源代码映射工具,它可以将压缩后的代码映射回构建前的状态。其原理是通过保存代码处理前后在行、列上的对应关系,形成类似“映射”的结构,生成对应的Source Map文件。如果处理后的代码在运行时抛出异常,就可以借助它快速查找到对应的原始代码。Source Map文件结构示例如下。

js
{
   version : 3,
   file :  bundle.js ,
   mappings :  AACAA,QAAQC,IADM ,
   sources : [
     webpack://studysourcemap/./test.js 
  ],
   sourcesContent : [
     const value = 123;\nconsole.log(value); 
  ],
   names : [
     console ,
     log 
  ],
   sourceRoot : ""
}

主要包括7个字段,每个字段的作用如下。

  • version:用于解析代码映射的Source Map版本。
  • sources:代码合并前的所有文件的路径。
  • names:代码中的所有变量和方法的名称。
  • mappings:记录代码在压缩处理前后的映射位置。
  • file:压缩处理后的代码生成的文件名。
  • sourcesContent:压缩处理前的源代码。
  • sourceRoot:压缩处理前的源文件所在的根路径目录。

因为Source Map文件可以逆向生成处理前的源代码,所以Source Map文件不允许被暴露在生产环境中。为了便于排查问题,开发人员一般会将Source Map文件上传到内网服务器中,只有内部员工才能访问,这样就不会导致代码泄露。

Released under the MIT License.