SourceMap 与前端异常监控

简单介绍下 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());

}

};

学习笔记

主要内容包括html,css,html5,css3,JavaScript,正则表达式,函数,BOM,DOM,jQuery,AJAX,vue等等

开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】

HTML/CSS

**HTML:**HTML基本结构,标签属性,事件属性,文本标签,多媒体标签,列表 / 表格 / 表单标签,其他语义化标签,网页结构,模块划分

**CSS:**CSS代码语法,CSS 放置位置,CSS的继承,选择器的种类/优先级,背景样式,字体样式,文本属性,基本样式,样式重置,盒模型样式,浮动float,定位position,浏览器默认样式

HTML5 /CSS3

**HTML5:**HTML5 的优势,HTML5 废弃元素,HTML5 新增元素,HTML5 表单相关元素和属性

**CSS3:**CSS3 新增选择器,CSS3 新增属性,新增变形动画属性,3D变形属性,CSS3 的过渡属性,CSS3 的动画属性,CSS3 新增多列属性,CSS3新增单位,弹性盒模型

JavaScript

**JavaScript:**JavaScript基础,JavaScript数据类型,算术运算,强制转换,赋值运算,关系运算,逻辑运算,三元运算,分支循环,switch,while,do-while,for,break,continue,数组,数组方法,二维数组,字符串

7b77e7667be59eecb.png)

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());

}

};

学习笔记

主要内容包括html,css,html5,css3,JavaScript,正则表达式,函数,BOM,DOM,jQuery,AJAX,vue等等

开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】

HTML/CSS

**HTML:**HTML基本结构,标签属性,事件属性,文本标签,多媒体标签,列表 / 表格 / 表单标签,其他语义化标签,网页结构,模块划分

**CSS:**CSS代码语法,CSS 放置位置,CSS的继承,选择器的种类/优先级,背景样式,字体样式,文本属性,基本样式,样式重置,盒模型样式,浮动float,定位position,浏览器默认样式

[外链图片转存中…(img-py0eDtzl-1714325670578)]

HTML5 /CSS3

**HTML5:**HTML5 的优势,HTML5 废弃元素,HTML5 新增元素,HTML5 表单相关元素和属性

**CSS3:**CSS3 新增选择器,CSS3 新增属性,新增变形动画属性,3D变形属性,CSS3 的过渡属性,CSS3 的动画属性,CSS3 新增多列属性,CSS3新增单位,弹性盒模型

[外链图片转存中…(img-FQ7hlB2E-1714325670578)]

JavaScript

**JavaScript:**JavaScript基础,JavaScript数据类型,算术运算,强制转换,赋值运算,关系运算,逻辑运算,三元运算,分支循环,switch,while,do-while,for,break,continue,数组,数组方法,二维数组,字符串

  • 3
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值