来看看字节是如何做前端异常监控的

1819次阅读  |  发布于3年以前

背景

我们从事 Web 开发工作中,异常监控系统已经是我们朝夕相处的好助手,但是这些异常处理工具通常都是建立在 Web 生态,或者是假定运行在浏览器环境下的,但是当我们需要给一套跨端系统搭建一套类似的异常监控系统,并且期望该系统兼容 Web 生态,现有的工具很可能就不满足我们的需求了,因此我们需要考虑一套完整的异常监控系统整个链路将会涉及到哪些工具链,以及如何修改这些工具链来适配我们的跨端系统。

精彩的一天从查 Bug 开始

我们先从和我们程序员最息息相关的线上查 Bug 开始。

下面这种图,相信大家很多人都很熟悉,当我们收到线上 Bug 反馈或者收到报警电话时,第一时间基本就是去自己的监控平台去查看线上日志,大家很可能看到类似下面这张截图

有经验的老司机,立马就可以定位到自己代码里哪里出了问题,但是有没有仔细思考过整套监控系统是如何打通的呢?或者说如果有一天你的监控系统出了问题,你知道如何追查是哪个环节出了问题吗?

代码反解

有经验的老司机立马就反应过来了,不就是代码里集成下 Sentry 的 Client,然后每次把 SourceMap 也上传一份给 Sentry,线上遇到错误将错误上传给 Sentry Server,Sentry Server基于错误堆栈和 SourceMap 反解出原始的堆栈就可以了。是的,监控系统要解决的一个核心问题就是代码反解。

出于一些性能和安全等的考虑,通常我们发布到线上的代码,通常并非原始的代码,而是经过混淆压缩后的代码,即使不经过压缩,大部分的前端工程都会经过一个 build 的过程,这个过程里通常会包括代码的转换、打包和压缩等,这使得调试生成的代码变得异常困难,因此,我们需要一个工具帮我们解决这类调试问题。

SourceMap

SourceMap 几乎完美的解决了代码反解问题,其使用方式十分简单,我们在编译的时候除了生成最终产物 xxx.js 文件外还会额外生成一个 xxx.js.map 的文件,这个 map 文件里包含了原始代码及其位置映射信息,这样我们利用 xxx.js 和 xxx.js.map 就可以将 xxx.js 的代码及其位置完美的映射会源代码以及位置,这样我们的调试工具就可以基于这个 map 文件实现源码调试了。其原理虽然很简单,但是当我们在工程中实际应用 SourceMap 的时候,仍然会碰到这样或那样的问题。一个很常见的问题就是,为啥用户上报的错误没法反解为原始代码的错误堆栈了?

SourceMap 支持的全链路流程

SourceMap 的使用并非是简单的一个编译生成即可,其实际上是需要我们整个的工作链路进行配合,才能使得 SourceMap 可以正常工作,因此我们需要先看看我们的整个工作链路上哪些环节会涉及 SourceMap,以及可能会碰到哪些问题。

以一个业务场景为例,我们用 Vue 开发的应用部署到线上 -> 发生了异常 -> 上报到了 Sentry -> Sentry 帮我们将错误进行反解展示给我们。这个业务场景非常简单但是实际涉及到了很多 SourceMap 的处理。

Transformer

首先我们需要我们的 DSL Transformer 支持 SourceMap

// App.vue
<template>
  <div></div>
</template>
<script lang="ts">
  let x: 10;
export default {}
</script>
<style>
h1 {
  color: red;
}
</style>
<i18n>
{ "greeting": "hello" }
</i18n>

我们使用 @vue/compiler-sfc 将该 Vue 文件编译为一个 SFCRecord,此时 SFCRecord 里实际上包含了每个 block 的 SourceMap

const { parse } = require('@vue/compiler-sfc');
const fs = require('fs');
const path = require('path');

async function main(){
  const content = fs.readFileSync(path.join(__dirname, './App.vue'),'utf-8');
  const sfcRecord = parse(content);
  const map = sfcRecord.descriptor['styles'][0].map
  console.log('sfc:',map) // 打印style的SourceMap
}

main();

我们可以进一步的根据每个 block 的 tag 和 lang 来继续 transform 每个 block,如使用 Babel | Typescript 处理 Script,PostCSS来处理 Style,Pug 来处理 Template,这里每个 Transformer 也都需要处理 SourceMap。

Bundler

处理完 Vue 文件的编译后,我们希望通过一个 bundler 来处理 Vue 模块的打包,此时我们可以使用esbuild、rollup、或者 Webpack,我们这里使用 rollup-plugin-vue 来配合 rollup 给 Vue 应用进行打包。

async function bundle(){
  const bundle = await rollup.rollup({
    input: [path.join(__dirname, './App.vue')],
    plugins:[rollupPluginVue({needMap:true})],
    external: ['vue'],
    output: {
      sourcemap:'inline'
    }

  })
  const result = await bundle.write({
    output: {
      file: 'bundle.js',
      sourcemap:true
    },
  })
  for(const chunk of result.output){
    console.log('chunk:',chunk.map) // SourceMap
  }

}
bundle()

因此这里也需要 bundle 在进行 bundler 的过程中同时处理生成 sourcemap。

Minifier

但我们 bundler 完代码后,还需要将代码进行压缩混淆才能发布到线上,这时我们需要使用 minify 工具进行混淆压缩。我们使用 terser 进行压缩。压缩时不仅需要处理 minfy 过程生成的 SourceMap 还需要处理其和原始 bundler 生成的 SourceMap 合并的问题,否则 SourceMap 和经过压缩处理的代码对应不上了。

  for(const chunk of result.output){
    console.log('chunk:', chunk.map)
    const minifyResult = await require('terser').minify(chunk.code, {
      sourceMap:true,
    })
    console.log('minifyMap:', minifyResult.map)
  }

Runtime

经过一番折腾,我们的编译流程终于处理完 SourceMap 了,我们开发过程中突然发现了代码出问题了,我们希望错误的堆栈能显示源码的位置,另外能支持源码调试应用,这时候就需要用的浏览器的 SourceMap 支持和 node 的 SourceMap 支持了。

日志收集和上报

经过一番眼花缭乱的操作,我们的代码终于和 SourceMap 对应上了,我们平稳的将业务部署上线,上线前我们需要确保我们的错误能够以正确的格式上报到我们的日志平台,然而我们线上运行的平台那么多样,运行的 JS 引擎也是各式各样,我们需要将用户的错误统一成一个格式上报给平台,幸运的是 Sentry 的客户端已经帮我们做了这件事情。我们只需要考虑接入 Sentry 的客户端就行了。因为如果直接将 SourceMap 一起跟随 js 代码下发,这就导致用户可以直接窥探你的源码了,类似发生这样的事情就很尴尬了https://zhuanlan.zhihu.com/p/26033573,因此我们还需要考虑将 SourceMap 发布到内网而非公网上,这时就需要处理 SourceMap 关联的问题了。

错误日志反解

一切都妥当了,只需要等用户的错误上报上来(最好永远别来),我就可以在 Sentry 上查看用户的原始错误堆栈,帮用户排查问题了,这时候实际上 Sentry Server 端偷偷帮我们做了根据用户的错误栈和用户的 SourceMap,帮我们反解错误栈的事情了。

总结一下,一个完整的 SourceMap 流程支持包括了如下这些步骤:

上面这些流程,基本上大多数工具都帮我们封装好了,我们只需要安心使用即可,但是当某天你需要自己开发一个自定义的 DSL 的 transformer 通过自研的 bundler 进行编译打包,运行在自研的 JS 引擎上并且使用自研的 monitor client 上报到自研的 apm 平台上,任何环节的出错都可能导致你线上的错误日志反解前功尽弃,你所能做的就是在整个链路上进行分析定位。

我们接下来就看看整个链路上有多少种出错的风险和可能,并且如何定位修复这些问题。

SourceMap 格式

首先我们需要了解下 SourceMap 的基本格式

我们将一个 .ts 文件编译为 .js 文件,看看其 SourceMap 信息是如何处理映射的。我们项目包含了原始的 ts 文件 add.ts、编译后的产物文件 add.js 和 SourceMap 文件 add.js.map,其内容如下

const add = (x:number, y:number) => {
  return x + y;
}
var add = function (x, y) {
    return x + y;
};
//# sourceMappingURL=module.js.map

SourceMap 的规范本身十分精简和清晰,其本身是一个 JSON 文件,包含如下几个核心字段

{
   version : 3, // SourceMap标准版本,最新的为3
   file: "add.js", // 转换后的文件名
   sourceRoot : "", // 转换前的文件所在目录,如果与转换前的文件在同一目录,该项为空
   sources: ["add.ts"], // 转换前的文件,该项是一个数组,表示可能存在多个文件合并
   names: [], // 转换前的所有变量名和属性名,多用于minify的场景
   sourcesContent: [ // 原始文件内容
    "const add = (x:number,y:number) => {\n  return x+y;\n}"
  ]
  mappings: "AAAA,IAAM,GAAG,GAAG,UAAC,CAAQ,EAAC,CAAQ;IAC5B,OAAO,CAAC,GAAC,CAAC,CAAC;AACb,CAAC,CAAA",
 }

简单介绍下 mapping 的格式,mapping 实际上是个三级结构,我们以上述的例子为例

lines = mappings.split(';')
//  [
  'AAAA,IAAM,GAAG,GAAG,UAAC,CAAQ,EAAC,CAAQ', // var add = function (x, y) {
  'IAC5B,OAAO,CAAC,GAAC,CAAC,CAAC',     // return x + y;
  'AACb,CAAC,CAAA' // };
]

其中每一行都对应生成代码的每行文件的位置映射信息,这里的三行分别对应了编译产物的三行信息

const segments = lines.map(x => {
  return x.split(',')
})

console.log('segments:',segments)
//  [
  [
    'AAAA', 'IAAM',
    'GAAG', 'GAAG',
    'UAAC', 'CAAQ',
    'EAAC', 'CAAQ'
  ], 
  [ 'IAC5B', 'OAAO', 'CAAC', 'GAAC', 'CAAC', 'CAAC' ],
  [ 'AACb', 'CAAC', 'CAAA' ]
]

这里 field 存储的值并非是直接的数字值,而是将数字使用 vlq 进行了编码,根据上述这些信息我们实际上就可以实现 SourceMap 的双向映射了,即可以根据 SourceMap 和原始代码的位置信息查找到生成代码的信息,也可以根据 SourceMap 和生成代码的位置信息,查找到原始代码的信息。接下来我们就实践下如何进行代码位置的双向查找。

双向查找流程

vlq 解码

首先第一步我们需要将 vlq 编码的 SourceMap 反解为原始的数字偏移信息,我们可以直接使用封装好的 vlq 库完成这一步

function decode() {
  const { decode} = require('vlq')
  const mappings = JSON.parse(result.sourceMapText).mappings;
  console.log('mappings:', mappings)
  /**
   * @type {string[]}
   */
  const lines = mappings.split(';');
  const decodeLines = lines.map(line => {
    const segments = line.split(',');
    const decodedSeg = segments.map(x => {
      return decode(x)
    })
    return decodedSeg;
  })
  console.log(decodeLines)
}

此时我们得到一个解码后的位置信息

[
  [
    [ 0, 0, 0, 0 ],
    [ 4, 0, 0, 6 ],
    [ 3, 0, 0, 3 ],
    [ 3, 0, 0, 3 ],
    [ 10, 0, 0, 1 ],
    [ 1, 0, 0, 8 ],
    [ 2, 0, 0, 1 ],
    [ 1, 0, 0, 8 ]
  ],
  [
    [ 4, 0, 1, -28 ],
    [ 7, 0, 0, 7 ],
    [ 1, 0, 0, 1 ],
    [ 3, 0, 0, 1 ],
    [ 1, 0, 0, 1 ],
    [ 1, 0, 0, 1 ]
  ],
  [ [ 0, 0, 1, -13 ], [ 1, 0, 0, 1 ], [ 1, 0, 0, 0 ] ]
]

还原绝对位置索引

此时的这些位置信息都是相对位置,我们需要将其还原为绝对位置

  const decoded = decodeLines.map((line) => {
    absSegment[0] = 0; // 每行的第一个segment的位置要重置
    if (line.length == 0) {
      return [];
    }
    const absoluteSegment = line.map((segment) => {
      const result = [];
      for (let i = 0; i < segment.length; i++) {
        absSegment[i] += segment[i];
        result.push(absSegment[i]);
      }
      return result;
    });
    return absoluteSegment;
  });
  console.log('decoded:', decoded)
}

结果如下,此时为绝对位置映射表

 [
  [
    [ 0, 0, 0, 0 ],
    [ 4, 0, 0, 6 ],
    [ 7, 0, 0, 9 ],
    [ 10, 0, 0, 12 ],
    [ 20, 0, 0, 13 ],
    [ 21, 0, 0, 21 ],
    [ 23, 0, 0, 22 ],
    [ 24, 0, 0, 30 ]
  ],
  [
    [ 4, 0, 1, 2 ],
    [ 11, 0, 1, 9 ],
    [ 12, 0, 1, 10 ],
    [ 15, 0, 1, 11 ],
    [ 16, 0, 1, 12 ],
    [ 17, 0, 1, 13 ]
  ],
  [ [ 0, 0, 2, 0 ], [ 1, 0, 2, 1 ], [ 2, 0, 2, 1 ] ]
]

双向映射

有了这个绝对位置映射,我们就可以构建源码和产物的双向映射了。我们可以实现两个核心 API

originalPositionFor 用于根据产物的行列号,查找对应源码的信息,而generatedPositionFor 则是根据源码的文件名、行列号,查找产物里的位置信息。

class SourceMap {
  constructor(rawMap) {
    this.decode(rawMap);
    this.rawMap = rawMap
  }

  /**
   * 
   * @param {number} line 
   * @param {number} column 
   */
  originalPositionFor(line, column){
    const lineInfo = this.decoded[line];
    if(!lineInfo){
      throw new Error(`不存在该行信息:${line}`);
    }
    const columnInfo = lineInfo[column];
    for(const seg of lineInfo){
      // 列号匹配
      if(seg[0] === column){
        const [column, sourceIdx,origLine, origColumn] = seg;
        const source = this.rawMap.sources[sourceIdx]
        const sourceContent = this.rawMap.sourcesContent[sourceIdx];
        const result = codeFrameColumns(sourceContent, {
         start: {
           line: origLine+1,
           column: origColumn+1
         }
        }, {forceColor:true})
        return {
          source,
          line: origLine,
          column: origColumn,
          frame: result
        }
      }
    }
    throw new Error(`不存在该行列号信息:${line},${column}`)
  }

  decode(rawMap) {
    const {mappings} = rawMap
    const { decode } = require('vlq');
    console.log('mappings:', mappings);
    /**
     * @type {string[]}
     */
    const lines = mappings.split(';');
    const decodeLines = lines.map((line) => {
      const segments = line.split(',');
      const decodedSeg = segments.map((x) => {
        return decode(x);
      });
      return decodedSeg;
    });
    const absSegment = [0, 0, 0, 0, 0];
    const decoded = decodeLines.map((line) => {
      absSegment[0] = 0; // 每行的第一个segment的位置要重置
      if (line.length == 0) {
        return [];
      }
      const absoluteSegment = line.map((segment) => {
        const result = [];
        for (let i = 0; i < segment.length; i++) {
          absSegment[i] += segment[i];
          result.push(absSegment[i]);
        }
        return result;
      });
      return absoluteSegment;
    });
    this.decoded = decoded;
  }
}

const consumer = new SourceMap(rawMap);

console.log(consumer.originalPositionFor(0,21).frame)

我们还可以使用 codeFrame 直接可视化查找出源码的上下文信息

generatedPositionFor 的实现原理类似,不再赘述。

事实上上面这些反解流程并不需要我们自己去实现,https://github.com/mozilla/source-map 已经帮我们提供了很多的编译方法,包括不限于

Mapping {
  generatedLine: 2,
  generatedColumn: 17,
  lastGeneratedColumn: null,
  source: 'add.ts',
  originalLine: 2,
  originalColumn: 13,
  name: null
}
Mapping {
  generatedLine: 3,
  generatedColumn: 0,
  lastGeneratedColumn: null,
  source: 'add.ts',
  originalLine: 3,
  originalColumn: 0,
  name: null
}
Mapping {
  generatedLine: 3,
  generatedColumn: 1,
  lastGeneratedColumn: null,
  source: 'add.ts',
  originalLine: 3,
  originalColumn: 1,
  name: null
}
Mapping {
  generatedLine: 3,
  generatedColumn: 2,
  lastGeneratedColumn: null,
  source: 'add.ts',
  originalLine: 3,
  originalColumn: 1,
  name: null
}

事实上 Sentry 的 SourceMap 反解功能也是基于此实现的。

SourceMap 全链路支持

前面我们已经介绍的 SourceMap 的基本格式,以及如何基于 SourceMap 的内容,来实现 SourceMap 的双向查找功能,大部分的 sourcmap 相应的工具链都是基于此设计的,但是在给整个链路做 SourceMap 支持的时候,但是链路的每一步需要解决的问题却各有不同(的坑),我们来一步步的研(踩)究(坑)吧。

给 transformer 添加 SourceMap 映射

Web 社区的主流语言的工具链都已经有了内置的 SourceMap 支持了,但是如果你自行设计一个 DSL 要怎么给其添加 SourceMap 支持呢?事实上 SourceMapGenerator 给我们提供了便捷的生成 SourceMap 内容的方法,但是当我们处理各种字符串变换的时候,直接使用其 API 仍然较为繁琐。幸运的是很多工具封装了生成 SourceMap 的操作,提供了较为上层的 api。我们自己实现 transformer 主要分为两种场景,一种是基于 AST 的变换,另一种则是对字符串(可能压根不存在 AST)的增删改查。

大部分的前端 transform 工具,都内置帮我们处理好了 SourceMap 的映射,我们只需要关心如何处理 AST 即可,以 babel 为例,并不需要我们手动的进行 SourceMap 节点的操作

import babel from '@babel/core';
import fs from 'fs';
const result = babel.transform('a === b;', {
  sourceMaps: true,
  filename: 'transform.js',
  plugins: [
    {
      name: 'my-plugin',
      pre: () => {
        console.log('xx');
      },
      visitor: {
        BinaryExpression(path, t) {
          let tmp = path.node.left;
          path.node.left = path.node.right;
          path.node.right = tmp;
        }
      }
    }
  ]
});
console.log(result.code, result.map);
// 结果
b === a; 
{
  version: 3,
  sources: [ 'transform.js' ],
  names: [ 'b', 'a' ],
  mappings: 'AAAMA,CAAN,KAAAC,CAAC',
  sourcesContent: [ 'a === b;' ]
}
const MagicString = require('magic-string');
const s = new MagicString('problems = 99');

s.overwrite(0, 8, 'answer');
s.toString(); // 'answer = 99'

s.overwrite(11, 13, '42'); // character indices always refer to the original string
s.toString(); // 'answer = 42'

s.prepend('var ').append(';'); // most methods are chainable
s.toString(); // 'var answer = 42;'

const map = s.generateMap({
  source: 'source.js',
  file: 'converted.js.map',
  includeContent: true
}); // generates a v3 SourceMap

console.log('code:', s.toString());
console.log('map:', map);
// 结果 
code: var answer = 42;
map: SourceMap {
  version: 3,
  file: 'converted.js.map',
  sources: [ 'source.js' ],
  sourcesContent: [ 'problems = 99' ],
  names: [],
  mappings: 'IAAA,MAAQ,GAAG'
}

我们发现对于简单的字符串处理,magic-string 比使用 AST 的方式要方便和高效很多。

SourceMap 验证

当我们给我们的 transformer 加了 SourceMap 支持后,我们怎么验证我们的 SourceMap 是正确的呢?你除了可以使用上面提到的 SourceMap 库的双向反解功能进行验证外,一个可视化的验证工具将大大简化我们的工作。esbuild 作者就开发了一个 SourceMap 可视化验证的网站 https://evanw.github.io/source-map-visualization/ 来帮我们简化 SourceMap 的验证工作。

SourceMap 合并

当我们处理好 transformer 的 SourceMap 生成之后,接下来就需要将 transformer 接入到 bundler 了,一定意义上 bundler 也可以视为一种 transformer,只是此时其输入不再是单个源文件而是多个源文件。但这里牵扯到的一个问题是将 A 进行编译生成了 B with SourceMap1 接着又将 B 进一步进行编译生成了 C with SourceMap2,那么我们如何根据 C 反解到 A 呢?很明显使用 SourceMap2 只能帮助我们将 C 反解到 B,并不能反解到 A,大部分的反解工具也不支持自动级联反解,因此当我们将 B 生成 C 的时候,还需要考虑将 SourceMap1 和 SourceMap2 进行合并,不幸的是很多 transformer 并不会自动的处理这种合并,如 TypeScript,但是大部分的 bundler 都是支持自动的 SourceMap 合并的。

如在 Rollup 里,你可以在 load 和 transform 里返回 code 的同时,返回 mapping。Rollup 会自动将该 mapping 和 builder 变换的 mapping 进行合并,vite 和 esbuild 也支持类似功能。如果我们需要自己处理 SourceMap 合并该如何操作,社区上已经有库帮我们处理这个事情。我们简单看下

import ts from 'typescript';
import { minify } from 'terser';
import babel from '@babel/core';

import fs from 'fs';
import remapping from '@ampproject/remapping';
const code = `
const add = (a,b) => {
  return a+b;
}
`;

const transformed = babel.transformSync(code, {
  filename: 'origin.js',
  sourceMaps: true,
  plugins: ['@babel/plugin-transform-arrow-functions']
});
console.log('transformed code:', transformed.code);
console.log('transformed map:', transformed.map);

const minified = await minify(
  {
    'transformed.js': transformed.code
  },
  {
    sourceMap: {
      includeSources: true
    }
  }
);
console.log('minified code:', minified.code);
console.log('minified map', minified.map);

const mergeMapping = remapping(minified.map, (file) => {
  if (file === 'transformed.js') {
    return transformed.map;
  } else {
    return null;
  }
});

fs.writeFileSync('remapping.js', minified.code);
fs.writeFileSync('remapping.js.map', minified.map);
//fs.writeFileSync('remapping.js.map', JSON.stringify(mergeMapping));

我们来简单验证下效果

我们可以看出做了 mergeSourcemap 后可以成功的还原出最初的源码

性能 matters

我们支持好了上面的 SourceMap 生成和 SourceMap 合并了,迫不及待的在业务中加以使用了,但是却“惊喜”的发现整个构建流程的速度直线下降,因为 SourceMap 操作的开销实际上是非常可观的,在不需要 SourceMap 的情况下或者在对性能极其敏感的场景下(服务端构建),实际是不建议默认开启 SourceMap 的,事实上 SourceMap 对性能极其敏感,以至于 source-map 库的作者们重新用 rust 实现了 source-map,并将其编译到了 webassembly。

错误日志上报和反解

当我们处理好 SourceMap 的生成后,就可以进行日志上报了

Sentry

错误上报需要解决的一个问题就是统一上报格式问题,我们生产环境遇到的错误并非直接将原始的 Error 信息上报给服务端的,而是需要先进行格式化处理,如下面这种错误

function inner() {
  myUndefinedFunction();
}
function outer() {
  inner();
}
setTimeout(() => {
  outer();
}, 1000);

原始的错误堆栈如下

Sentry Client 会将其先进行格式化处理,Sentry 发送给后端的错误堆栈格式下面这种格式化数据

问题来了,为啥 Sentry 要经过这样一番格式化处理,以及格式化处理中可能会发生什么问题呢。

V8 StackTrace API

按理来讲 Error 对象作为标准里规定的 Ordinary Object ,其在不同的 JS 引擎上表现行为应该一致,但是很不幸,标准里虽然规定了 Error 对象是个 Ordinary Object,但也只规定了 name 和 message 两个属性的行为,对于最广泛使用的 stack 属性,并没有加以定义,这导致了 JS 引擎在 stack 属性的表现差别很大(目前已经有一个标准化 stack 的 proposal),甚至有的引擎实现已经突破了标准的限定,使得 Error 表现的更像一个 Exotic Object。我们来具体看看各引擎对于 Error 对象的实现差异。

V8 支持了 stack 属性,并且给 stack 属性提供了丰富的配置,如下是一个基本的 stack 信息。

function inner() {
  myUndefinedFunction();
}
function outer() {
  inner();
}
function main() {
  try {
    outer();
  } catch (err) {
    console.log(err.stack);
  }
}

main();

我们可以使用 https://github.com/GoogleChromeLabs/jsvu 来很方便的测试不同 JS 引擎的表现差异

V8 的 stacktrace默认最多展示 10 个 frame,但是该数目可以通过 Error.stackLimit 进行配置,同时 V8 也支持了 async stacktrace,默认也会展示 async|await 的错误栈。

stacktrace 的捕获不仅仅可以在出现异常时触发,还可以业务主动捕获当前的 stacktrace,这样我们就可以基于此实现自定义 Error 对象。

Error.captureStackTrace

V8 提供了 Error.captureStackTrace 支持用户自定义收集 stackTrace。

这个 API 主要有两种功能,一个是给自定义对象追加 stack 属性,达到模拟 Error 的效果

function CustomError(message) {
  this.message = message;
  this.name = CustomError.name;
  Error.captureStackTrace(this); // 给对象追加stack属性
}

try {
  throw new CustomError('msg');
} catch (e) {
  console.error(e.name); // CustomError
  console.error(e.message); //msg
  console.error(e.stack); 
  /*
    CustomError: msg
    at new CustomError (custom_error.js:4:9)
    at custom_error.js:7:9
  */
}

另一个作用就是可以隐藏部分实现细节,这一方面可以避免一些对用户无用的信息泄露给用户,而对用户造成困扰;另一方面可能有一些内部信息涉及一些敏感信息,需要防止泄露给用户。比如一般用户是不需要关心 native 的调用栈,因此就需要将 native 的调用栈进行隐藏。下面的例子就简单的演示了如何通过 captureStackTrace 来隐藏部分调用栈信息。

function CustomError(message, stripPoint) {
  this.message = message;
  this.name = CustomError.name;
  Error.captureStackTrace(this, stripPoint);
}

function leak_secure() {
  throw new CustomError('secure泄漏了');
}

function hidden_secure() {
  throw new CustomError('secure没泄露', outer_api);
}

function outer_api() {
  try {
    leak_secure();
  } catch (err) {
    console.error('stk:', err.stack);
  }

  try {
    hidden_secure();
  } catch (err) {
    console.error('stk2:', err.stack);
  }
}

outer_api();

Error.prepareStackTrace

另一个值得注意点的是,虽然 stack 名义上应该是一个 frame 的数组,但是实际上 stack 却是个字符串(历史包袱,兼容性问题吧),因此 V8 同时提供了一个结构化的 stack 信息,方便用户根据结构化的 stack 信息来自定义 stack 结构。我们可以通过 Error.prepareStackTrace 来获取原始的栈帧结构:

Error.prepareStackTrace = (error, structedStackTrace) => {
  for (const frame of structedStackTrace) {
    console.log('frame:', frame.getFunctionName(), frame.getLineNumber(), frame.getColumnNumber());
  }
};

其中的 structedStackTrace 是个包含了 frame 信息的数组,其中包含了很多我们感兴趣的信息,更详细的信息可参考 stack-trace-api。

另外 stack 虽然是个 value property ,但是实际表现却是个 getter property,这也是 V8 的实现违反 EcmaScript 规范的地方。

这里的 stack 虽然是个 value,但是其表现其实更像是一个 getter,因为其访问 stack 的属性会触发 prepareStackTrace 回调。这导致 error.stack 实际上是可能存在副作用的。不仅如此, stack 属性的计算也是惰性计算的,当 error 触发的时候并不会进行 stack 的计算,而只有当首次访问 stack 的时候才会触发计算,因为本身 stack 的计算实际上是有一定的性能开销的,实际上 chrome devtools 就因为 stackstrace 的性能问题出过问题(faster-stack-trace),笔者也因为 stack-trace 的性能问题导致严重影响了编译工具的编译性能(https://github.com/evanw/esbuild/issues/1039)。

Stack Trace Format

前面已经提到 V8 的 stack 是个字符串,如果需要将一个字符串解析为一个结构化数据,势必该字符串需要符合某个格式规范,幸运的是 V8 的有一套格式规范,具体格式可见 stack-trace-format。虽然 V8 引擎定义了一套格式规范,不幸的是其他引擎的 stack 格式规范与 V8 截然不同(不带重样的),我们来看看不同引擎的格式规范。

V8(Chrome)

SpiderMonkey(Firefox)

JavaScriptCore(Safari)

QuickJS

我们发现四个 JS 引擎的 stack 格式各不相同,因此需要我们在上报错误前需要将这些格式进行分别的格式化处理,幸运的是 Sentry Client 已经帮我们给主流的 JS 引擎做了适配。

sentry-compute-stack-trace

不幸的是,如果你的 JS 引擎是自研的或者 stack 格式和上述的引擎都不一致,你可能需要修改 Sentry 加以支持。

这里我们还发现了一个比较严重的问题就是 QuickJS 引擎的报错是没有行列号信息的,这对于平时的开发可能足够了,但是如果你将你的代码压缩成一行,那么就会导致行列号信息都被丢失了,那么上报的错误是根本没法进行反解的。更加不幸的是 QuickJS 至今仍然不支持列信息,如果你的系统里使用了 QuickJS,可能需要修改 QuickJS 自行添加列号支持。

eval is eval

如果你的代码是在 eval 里执行,那么将会碰到另一个问题,有的 JS 引擎针对 eval 的代码并不会包含错误的行列号信息。

const code = `function inner() {
  myUndefinedFunction();
}

function outer() {
  inner();
}

function main() {
  try {
    outer();
  } catch (err) {
    console.log(err.stack);
  }
}

function foo() {
  bar();
}

function bar() {
  main();
}

foo();

eval(code);

比如我们看一下不同引擎的结果

其实为了解决 eval 的错误堆栈的行列号问题,我们可以借助 sourceURL 进行还原,我们来看一看结果

const code = `function inner() {
  myUndefinedFunction();
}
function outer() {
  inner();
}
function main() {
  try {
    outer();
  } catch (err) {
    console.log(err.stack);
  }
}

function foo() {
  bar();
}
function bar() {
  main();
}

foo()
//# sourceURL=my-foo.js 
`; // 这里通过sourceURL支持还原

eval(code);

V8:成功还原

JavaScriptCore:不支持

SpiderMonkey:成功还原

anonymous function is bad

解决了 eval 的问题后,似乎可以高枕无忧了,但是实际开发中仍然碰到了一些匪夷所思的问题,线上反解仍然失败,后来定位发现 JavaScriptCore 在生成异常的时候,其行列号可能计算错误,以及给 QuickJS 引擎添加的行列号功能也存在不少 bug。那么在行列号不靠谱的情况下如何查问题。那就只能借助于 function name 去全局搜索了,不幸的是我们的业务中和 SDK 中存在大量的匿名函数,这无异于雪上加霜。

对 YDKJS 的观点深感赞同,不幸的是 JavaScript 里将 anonymous function 和 lexical this 两个 feature 糅合在一起了,你除了通过变量声明的方式,没有其他更简洁的方式来给一个 arrow function 进行命名

const xxx = () => {} // xxx.name is xxx
call('login', () => {
   this.crash()
}) // 如果这里crash了,很不幸调用栈里拿不到函数名了

const cb = () => {this.crash()}
call('login', cb) // 太绕了。。。。

call('login', function loginCb(){
   this.crash()
}.bind(this)) // 还是很绕

call('login', loginCb() => { // 这样就最好了,可惜不支持,we need a proposal 
  this.crash();
})

如果 arrow function 也能便捷的命名就好了。

SourceMap 的局限

代码反解只是 SourceMap 的一个功能,其实还扮演着源码调试等功能,但是 SourceMap 在源码调试上却面临着更大的问题和挑战,比如难以应对其他高级语言的转换问题,例如 C++ 编译到 asm.js 或者 C++编译为 wasm,如何处理 wasm 或者 asm.js 的源码调试和代码反解,是另一个比较复杂的话题了。

参考文献

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8