酷家乐基于前端错误栈实现根因应用的解析

277 阅读12分钟

背景

在前端监控中,处理前端异常日志的方式通常类似于后端日志处理。通过进行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,再对外提供警报计算、异常定位、自定义分析等功能场景;

image.png

是前端查询时分析错误栈关联应用还是Flink数据实时解析?

在前面我们讨论过,我们不仅要解决异常问题精准聚类的问题,还要实现在警报计算中完善精确定位,因此及解析错误栈必需实时处理,也就是需要对所有前端异常数据解析出关联应用; 因此我们设计如下:

image.png

  • 新增一个 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,这是一个开源代码,可以参考,实现了对错误栈中每一个栈的基本信息解析,基本原理使用正则的方式提取;

github.com/stacktracej…
image.png

这两个方法,由于我们要在流计算中实现全量打标,因此这段解析代码我们将在 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 通信;

image.png 基于缓存模式,我们构建了错误栈解析服务,下面重点描述 Flink 如何与 alertstack 通信;

Flink 与 alertstack 的通信模式

Flink 开发中最为关心性能问题,如果处理不好,则会导致计算效率极低。因此和 alertstack 的交互需要细致思考;

最简单的做法是,当判断是新的 错误栈就调用 HTTP 请求解析应用,并将结果缓存起来。但是完全等HTTP返回再处理下一条数据,那是完全无法满足 Flink 的处理速度。有时候解析一个错误栈涉及sourceMap 的下载和将其加载到内存解析,这两步都是高耗时操作。

因此经过多方面考虑,我们采取了解耦的设计方式,将 Flink 的关联应用打标和 解析结果获取独立开来,详细处理逻辑如下:

  • 首先Flink 启动时从 alertstack 拉取一次全量解析结果,并将其缓存起来;
  • Flink 数据处理过程中,先查询本地缓存,如果没有缓存则将这个错误栈信息放入 解析暂存区;
  • 当暂存区累积满足指定条件时(数量和间隔时间任意达标),异步发起一个 http 请求到 alertstack,完成解析任务的提交;
  • Flink 缓存任务定时从 错误栈解析应用中拉取 最近解析的结果,并追加到本地缓存;
  • Flink 处理过程中,如果在本地缓存找不到对应的解析结果,对于此条数据直接跳过错误栈解析;

在本地缓存中需要恰当地使用LRU策略,否则最后会出现OOM,拉取缓存结合Fink 的 SourceFunction 和 RichCoFlatMapFunction 实现,整个时序如下:

image.png 到此我们就介绍完三大部分,整个架构现在已经在酷家乐生产环境运行,满足预期。后面我们将简单分享下数据的处理效果和性能。

解析效率和效果

解析效率

在实际情况中,并不是每一条错误日志的错误栈都是唯一的,可能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%的解析成功率下,完全满足需求。

最后感谢大家能读到这里,你的建议是我们前进最大的动力!