背景
在前端监控中,处理前端异常日志的方式通常类似于后端日志处理。通过进行ETL处理,可以为公司的研发成员提供日志查询功能。这是常规的数据流模式。酷家乐前端主要采用微应用架构,该架构通过模块化、独立运行、集成和前后端解耦等方式解决了前端应用开发和维护过程中的问题,提高了开发效率和用户体验。微应用架构是当前互联网企业广泛采用的一种前端架构模式。然而,基于微应用构建的前端体系存在一个难题,即难以将收集的日志准确关联到目标应用程序。因为在应用A中打印的错误日志不一定由A本身导致,也可能是调用了应用B,并由B的BUG导致的错误。因此,这种微应用架构给错误日志关联应用带来了困难。
为什么要将错误日志关联到准确的应用?
- 问题聚类: 通常情况下,错误日志会根据应用名称、异常类型和异常信息进行分组聚合。然而,往往会出现异常类型甚至异常信息完全相同,但根本原因却完全不同的情况。因此,将根因应用加入分组可实现更好的聚类效果。
- 警报准确性: 前端错误日志不仅用于查询,还用于配置警报。频繁收到与自己负责的应用无关的警报通知会降低对警报的关注度。
- 分析挑战: 在处理大量日志时,直接查询日志原文并进行问题分析并不是最佳方式。首先进行分组聚合,从整体上了解问题的影响范围,并与相关负责人协同排查问题,这是一种更快速的方法。
基于以上几点原因,对前端错误日志完成关联应用的解析势在必行。
如何定位关联应用呢?
在前端错误日志中,会同时打印出异常栈,一般格式如下,从下到上表示方法的最高层到最底层的调用关系:
// sample:
DWDT-CallStack: [DWDT] syncFromFloorplan
at c.value (https://****/entry1.316f480bf5159c797a03.js?pn=kujiale-****-party&ps=1220503:2812:14814)
at c.value (https://****/entry1.316f480bf5159c797a03.js?pn=kujiale-****-party&ps=1220503:2812:13905)
at https://****/entry1.316f480bf5159c797a03.js?pn=kujiale-****-party
由于前端代码一般经过混淆,因此直接看错误栈看不出太多信息,需要经过 sourcemap 还原,才可以看到每一层包含:
- 方法名称;
- 方法所在行列号;
- 代码文件所在位置:进一步解析出微应用名称。所以错误栈必须要经过 sourcemap 的解析;
- 自定义参数:可解析出业务应用名;
解析出每一行详细后,还需要根据一定的规则来选定哪一行关联的微应用才是准确的。这个规则需要根据经验和公司架构来自己确定;
在酷家乐 前端错误日志的处理流程大致如下,通过API服务收集前端监控数据,经过 Flink 处理后将其处理后入库到 ClickHouse,再对外提供警报计算、异常定位、自定义分析等功能场景;
是前端查询时分析错误栈关联应用还是Flink数据实时解析?
在前面我们讨论过,我们不仅要解决异常问题精准聚类的问题,还要实现在警报计算中完善精确定位,因此及解析错误栈必需实时处理,也就是需要对所有前端异常数据解析出关联应用; 因此我们设计如下:
- 新增一个 alertstack go服务,核心功能只实现对错误栈的解析;
- 面向 Flink ,提供错误栈的解析任务提交 、缓存、批量获取等功能;
后面我们将以 错误栈的解析原理、解析服务 alertstack 的实现、Flink 的通信模式 3个部分介绍整个前端错误日志错误栈解析功能的构建;
错误栈解析原理
错误栈格式区分
错误栈样式主要区分两种浏览器,分别是Chrome 和 Mac 的 Safari ,在解析过程中需要区分处理
chrome 格式:
// sample 1:
DWDT-CallStack: [DWDT] syncFromFloorplan
at c.value (https://****/entry1.316f480bf5159c797a03.js?pn=kujiale-****-party&ps=1220503:2812:14814)
at c.value (https://****/entry1.316f480bf5159c797a03.js?pn=kujiale-****-party&ps=1220503:2812:13905)
at https://****/entry1.316f480bf5159c797a03.js?pn=kujiale-****-party
safari 格式:
// sample 1:
@https://****/pages/index/entry.db47142a3413f49fcf3d.js:390:7598
@https://****/pages/index/entry.db47142a3413f49fcf3d.js:390:8618
@https://****/pages/index/entry.db47142a3413f49fcf3d.js:390:4296
// sample 2:
Y@https://****/entry.c2d5eb3d662adc8eeb9f.js:642:180672
@https://****/entry.c2d5eb3d662adc8eeb9f.js:649:66037
He@https://****/entry.c2d5eb3d662adc8eeb9f.js:908:33635
@https://****/entry.c2d5eb3d662adc8eeb9f.js:908:32545
Promise@[native code]
f@https://****/entry.c2d5eb3d662adc8eeb9f.js:908:32325
错误栈解析
主要目的:从栈中获取 line, column, url ,url 可以解析成 获取 sourceMap 的地址,line, column 用于 sourceMap 反解析 真实 fileName、 line、column、func,fileName 是该代码所在文件路径,其中包含二方包名称;
用正则初步解析
通过正则可以初解析出 line, column, url,这是一个开源代码,可以参考,实现了对错误栈中每一个栈的基本信息解析,基本原理使用正则的方式提取;
这两个方法,由于我们要在流计算中实现全量打标,因此这段解析代码我们将在 golang 程序里重写(这里推荐一个正则验证工具 regex101.com,调试捕获组很方便)
实现将一个栈的 url 转为 sourceMap 的url:
// 一个栈
at new V (https://****/entry.d8c9d09ffb1bc8f4921d.js?pn=kujiale-****-party-kaf&ps=1214979:635:178659)
// 提取 URL
https://****/entry.d8c9d09ffb1bc8f4921d.js
// 生成 map url:取 ${host} + pub_source_map + ${最后一个文件名} + .map
https://****/pub_source_map/entry.d8c9d09ffb1bc8f4921d.js.map
用 SourceMap 解析
使用 sourceMap 解析,才能得到真实的 fileName、 line、column、func。我们正是需要 fileName,才能获取其中的微应用名称等信息;
sourceMap 解析 line、column 在 GitHub 上有 golang 开源版本:github.com/go-sourcema…
func ExampleParse() {
mapURL := "http://code.jquery.com/jquery-2.0.3.min.map"
resp, err := http.Get(mapURL)
if err != nil {
panic(err)
}
defer resp.Body.Close()
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
panic(err)
}
smap, err := sourcemap.Parse(mapURL, b)
if err != nil {
panic(err)
}
line, column := 5, 6789
file, fn, line, col, ok := smap.Source(line, column)
fmt.Println(file, fn, line, col, ok)
// 这个 file 里就有我们要的包名;
// Output: http://code.jquery.com/jquery-2.0.3.js apply 4360 27 true
}
可以看到可以解析出 file, fn, line, col,整个解析流程基本就走完了,后面将使用 golang 来开发一个支持传入错误栈输出解析后结果的MVP版本:
var (
stackParserOriginIns = &StackParserOrigin{
// 2 funcName 4 fileName 5 lineNum 6 columnNum
chromeRegex: regexp.MustCompile(`at ((.*) )?(\()?(.*/(.*\.js)).*:(\d+):(\d+)(\))?`),
// 1 funcName 2 fileName 3 lineNum 4 columnNum
safariRegex: regexp.MustCompile(`(.+)?@(.*/(.*\.js)):(\d+):(\d+)`),
// 1 fileUrl 2 fileName
urlFileRegex: regexp.MustCompile(`(http.*/(.*\.js))(\?pn=(.*)&)?`),
}
)
// ParseStackInRegex 初解析原始文本格式错误栈
func (s *StackParserOrigin) ParseStackInRegex(stack string) []*models.StackLineParsed {
parsed := make([]*models.StackLineParsed, 0)
for _, line := range strings.Split(stack, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "at ") {
parts := s.chromeRegex.FindStringSubmatch(line)
if parts != nil && len(parts[5]) > 0 {
lineNumber, err1 := strconv.Atoi(parts[6])
ColumnNumber, err2 := strconv.Atoi(parts[7])
if err1 == nil && err2 == nil {
parsed = append(parsed, &models.StackLineParsed{
FunctionName: parts[2],
FileUrl: parts[4],
FileName: parts[5],
LineNumber: lineNumber,
ColumnNumber: ColumnNumber,
MapUrl: s.toMapUrl(parts[5]),
})
}
}
} else {
parts := s.safariRegex.FindStringSubmatch(line)
if parts != nil && len(parts[3]) > 0 {
lineNumber, err1 := strconv.Atoi(parts[4])
ColumnNumber, err2 := strconv.Atoi(parts[5])
if err1 == nil && err2 == nil {
parsed = append(parsed, &models.StackLineParsed{
FunctionName: parts[1],
FileUrl: parts[2],
FileName: parts[3],
LineNumber: lineNumber,
ColumnNumber: ColumnNumber,
MapUrl: s.toMapUrl(parts[3]),
})
}
}
}
}
return parsed
}
解析服务 alertstack 的实现
我们使用 Flink 流计算实现对前端错误日志的解析。如果要实现错误栈的解析,通常是使用 Flink 算子直接对错误栈解析,解析后的结果缓存在 State 中,如果量不大甚至用一个成员变量就可以缓存。
但是这会有几个问题:
- 错误栈的重复解析:在Flink 中如果要避免重复解析,就需要在上游做hash,使同一个错误栈流向同一个算子,一定程度上会导致数据处理不均。
- SourceMap文件无法共享,最坏情况是在多个管道中都会下一份相同的SourceMap,这无疑会造成资源的浪费;
- 仅能对流解析提供解析支持,无法对外部其他产品提供解析服务;
因此决定将解析功能独立为一个外部应用,对 Flink 提供解析后的结果访问,同时也能为内部其他平台提供解析支持。独立为一个服务需要考虑如下几个难点问题:
- 如何有效降低避免重复解析:对每一个错误栈计算一个唯一key;
- 如何避免重复下载 SourceMap:使用缓存记录下载过的文件,以及标记出此文件能否下载,防止无效下载请求,因为某些 sourcemap 并不能下载;
- Flink 如何 和 解析应用交互:这一部分将在 Flink 和 alertstack 通信模式中重点介绍;
解析应用支持对错误栈的解析,以及部分数据的持久化:
- 多级缓存:sourcemap 文件本身、以及经过 sourcemap 解析的stack 、package 均缓存起来;
- 缓存持久化:定时将缓存数据通过一定编码刷新到磁盘文件中;
- 重启读缓存:实例重启后,先从缓存文件中读取记录,构建缓存实例;
- 防流量冲击:新实例部署后,通过预解析一批stack,防止在没有缓存的情况下大流量请求;
因此设计了如下5个模块:
- Cache:缓存主要存放在内存中,但是会通过定时任务刷新到磁盘;
- Cache live manager:缓存生命周期管理:程序启动时从磁盘加载缓存,运行时定时删除内存中缓存过期数据,并将最新的内存缓存数据刷新到磁盘;
- Parse flow:一个错误栈到解析出一个关联应用;
- Manager:预解析实现模块、Flink 提交的解析任务管理;
- API:对外提供的接口服务,Flink 通过接口和 alertstack 通信;
基于缓存模式,我们构建了错误栈解析服务,下面重点描述 Flink 如何与 alertstack 通信;
Flink 与 alertstack 的通信模式
Flink 开发中最为关心性能问题,如果处理不好,则会导致计算效率极低。因此和 alertstack 的交互需要细致思考;
最简单的做法是,当判断是新的 错误栈就调用 HTTP 请求解析应用,并将结果缓存起来。但是完全等HTTP返回再处理下一条数据,那是完全无法满足 Flink 的处理速度。有时候解析一个错误栈涉及sourceMap 的下载和将其加载到内存解析,这两步都是高耗时操作。
因此经过多方面考虑,我们采取了解耦的设计方式,将 Flink 的关联应用打标和 解析结果获取独立开来,详细处理逻辑如下:
- 首先Flink 启动时从 alertstack 拉取一次全量解析结果,并将其缓存起来;
- Flink 数据处理过程中,先查询本地缓存,如果没有缓存则将这个错误栈信息放入 解析暂存区;
- 当暂存区累积满足指定条件时(数量和间隔时间任意达标),异步发起一个 http 请求到 alertstack,完成解析任务的提交;
- Flink 缓存任务定时从 错误栈解析应用中拉取 最近解析的结果,并追加到本地缓存;
- Flink 处理过程中,如果在本地缓存找不到对应的解析结果,对于此条数据直接跳过错误栈解析;
在本地缓存中需要恰当地使用LRU策略,否则最后会出现OOM,拉取缓存结合Fink 的 SourceFunction 和 RichCoFlatMapFunction 实现,整个时序如下:
到此我们就介绍完三大部分,整个架构现在已经在酷家乐生产环境运行,满足预期。后面我们将简单分享下数据的处理效果和性能。
解析效率和效果
解析效率
在实际情况中,并不是每一条错误日志的错误栈都是唯一的,可能100条错误日志,实际上只对应20条不同的错误栈。同时在 alertstatck 引入分层缓存后,sourcemap 原文件的缓存命中率达到 99% 以上,解析结果的缓存命中率达到 80% 以上。并且 Flink 也做了LRU缓存,缓存命中率达到约 99.5% 以上;
解析效果
在完成预解析,接入Flink 请求后,Flink 提交从未解析过的 stack 大约 60条/分钟,CPU 占用20% 非常健康;
在 alertstack 单实例 2C4G 配置条件下,使用缓存机制能为 Flink 每秒上万的处理速度提供解析支持。 同时解析成功率达到:98.8%,存在一些错误日志的错误栈无法正确解析,这部分包含错误栈格式特殊以及为空等等。
解析到责任人的正确率约 85% ,这和配置的解析规则有关,目前我们还在继续对比解析结果和预期,调整解析规则,努力提高准确率。
错误栈解析结果多为敏感信息,这里不再展示。
总结
- 本篇文章旨在介绍为何要使用sourcemap解析前端日志的错误堆栈,并调查了常见浏览器错误堆栈的格式。这些格式需要经过初步解析获取基本信息后,才能利用sourcemap进行还原。
- 初步解析可以使用基于正则表达式的开源工具,直接对文本进行解析。因此,我们可以在自己的程序中仿照这种方式进行实现。
- 初步解析完成后,我们需要使用sourcemap进行还原。在GitHub上也有相关的第三方库,包括GO和Java版本。在这里,我们选择了GO版本,并构建了一个独立的堆栈解析服务。
- 解析服务中设计了多级缓存、缓存持久化、重启读缓存和防止流量冲击等特性,完全满足现有场景的需求。
- 对于Flink任务的设计,我们采用了异步提交和异步加载的特性。虽然可能导致个别错误堆栈在入库前未完成解析,但这可以避免性能等问题。在达到98.8%的解析成功率下,完全满足需求。
最后感谢大家能读到这里,你的建议是我们前进最大的动力!