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

发表于 3年以前  | 总阅读数:1818 次

背景

我们从事 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 流程支持包括了如下这些步骤:

  • transformer: Babel、typescript、emscripten 、 esbuild
  • minifier: esbuild ,terser
  • bundler: esbuild, webpack, rollup
  • runtime: browser & node & deno
  • 日志上报: sentry client
  • 错误日志反解: sentry server && node-sourcemap-support

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

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

SourceMap 格式

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

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

  • add.ts
const add = (x:number, y:number) => {
  return x + y;
}
  • add.js
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 实际上是个三级结构,我们以上述的例子为例

  • line:每个 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' // };
]

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

  • segment:每一行同包含由 , 分割的多个 segment 信息,其中每个 segment 都对应了产物里每一行里每一个符合所在的列的信息
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' ]
]
  • fields:每个 segment 实际上又包含了几个 field,每个 field 都编码了具体的行列映射信息,依次为
  • 第一位: 转换后代码所处的列号,如果这是当前行的第一个 segment,那么是个绝对值,否则是相对于上一个 segment 的相对值
  • 第二位:表示这个位置属于 sources 属性中的哪一个文件,相对于前一个 segment 的位置(区别于列号,下一行的第一个 segment 仍然是相对于上一行的最后一个 segment,并不会 reset)
  • 第三位:表示这个位置属于转换前代码的第几行,相对位置,同第二列
  • 第四位:表示这个位置属于转换前代码的第几列,相对位置,同第二列
  • 第五位:表示这个位置属于 names 属性中的哪一个变量,相对位置,同第二列

这里 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 已经帮我们提供了很多的编译方法,包括不限于

  • originalPositionFor:查找源码位置
  • generatedPositionFor:查找生成代码位置
  • eachMapping:生成每个 segment 的详细映射信息
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)的增删改查。

  • 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;' ]
}
  • 但是 AST 并不能覆盖所有场景,例如我们如果需要将 c++ 或者 brainfuck 编译为 js,就很难找到便捷的工具,或者我们只需要替换代码里的部分内容,AST 分析就是大才小用了。此时我们可以使用 magic-string 来实现。
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));

我们来简单验证下效果

  • 使用 mergeMapping 之前

  • 使用 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);

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

  • V8 虽然包含了行列号信息,但是堆栈里含有了两个行列号信息,可能导致 sentry-client 识别出错

  • JavascriptCore 则彻底没有行列号信息

  • SpiderMonkey 虽然能够显示,但与非 eval 版本格式完全不同,很难兼容

其实为了解决 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 的源码调试和代码反解,是另一个比较复杂的话题了。

参考文献

  • https://github.com/Rich-Harris/vlq/tree/master/sourcemaps
  • https://www.ruanyifeng.com/blog/2013/01/javascript_source_map.html
  • https://tc39.es/ecma262/#sec-error-objects
  • getOwnPropertyDescriptor side effects
  • https://bugs.chromium.org/p/v8/issues/detail?id=5834
  • https://hacks.mozilla.org/2018/01/oxidizing-source-maps-with-rust-and-webassembly/

本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/1TQwNx0Q69qEBcoXwJ-Vpg

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237231次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8065次阅读
 目录