APM前端监控系统实践

4,028 阅读7分钟

APM介绍

前端APM(Application Performance Manage)是对线上运行状况进行监控的一种手段,通过APM能够实时监控线上的脚本异常、页面白屏、网络延迟,以及从各个维度对各个应用的健康状况进行评估。

整体架构图

监控SDK

数据采集

错误类型:主要分为以下几种
  • 语法错误、代码运行时发生的错误

  • 静态资源加载错误

  • Promise调用链异常

错误捕获方式
  • window.onerror

window.onerror 可以捕获 语法错误代码运行时发生的错误

this.global.onerror = this.handleJsError;
  • window.addEventListener('error')

window.addEventListener('error') 可以捕获 资源加载错误

this.global.addEventListener('error', this.handleResourceError, true);

但是,new Image().src = 'test.cn/×××.png' 这种错误无法被 addEventListener 捕获到,需要单独处理

  • window.addEventListener('unhandledrejection')

可通过 unhandledrejection 事件来处理 Promise 中抛出的错误,Promise error 无法被 window.onerror、try/catch、 error 事件捕获到

this.global.addEventListener('unhandledrejection', this.handlePromiseError);

性能指标采集

web-vitals是 Google 给出的定义是 一个良好网站的基本指标

过去要衡量一个网站的好坏,需要使用的指标太多了,现在我们可以将重点聚焦于 Web Vitals 指标的表现即可

官方指标标准

指标作用标准
FCP(First Contentful Paint)首次内容绘制时间标准 ≤1s
LCP(Largest Contentful Paint)最大内容绘制时间标准 ≤2s
FID(first input delay)首次输入延迟,标准是用户触发后,到浏览器响应时间标准≤100ms
CLS(Cumulative Layout Shift)累积布局偏移标准 ≤0.1
TTFB(Time to First Byte)页面发出请求,到接收第一个字节所花费的毫秒数(首字节时间)标准<=100ms

采集方式

利用 Google 提供的 web-vitals 库来获取指标

import {
  getFCP, getLCP, getFID, getCLS, getTTFB,
} from 'web-vitals';

init = (config) => {
    function addToQueue(metric) {
      // 在合适的时机进行上报
    }
    getFCP(addToQueue);
    getCLS(addToQueue);
    getFID(addToQueue);
    getLCP(addToQueue);
    getTTFB(addToQueue);
};

数据上报

我们APM中有两种数据上报方式:sendBeaconXmlHttpRequest

  • sendBeacon:使用浏览器后台队列,即使在页面关闭后也可以发送请求。因此,它适用于需要在页面卸载时发送数据的情况(当浏览器不支持 sendBeacon 或者 传输的数据量超过了 sendBeacon 的限制,我们就降级采用 xmlHttpRequest 进行上报数据)。
  • XMLHttpRequest (XHR):是一种传统的网络请求方法,可以用于发送各种类型的请求;

上报时机

  • 实时上报:对于脚本异常、资源加载异常、请求异常、白屏异常的数据,会实时上报

  • 条件上报:

    • 对于性能指标的数据,会在当浏览器的页面可见性状态发生变化用户即将离开页面关闭页面或导航到其他页面、面即将卸载这三种情况下进行上报

      为什么这个上报时机这么奇怪?因为LCP这些指标,页面加载完成的时候可能无法立即获得准确的值

    • 对于资源加载明细的数据,仅在LCP低于某个阈值时上报,用于分析问题

    • 对于服务器的响应数据,会在计算出TTFB这个指标时就马上上报(一般打开页面就能计算出来,所以把这个指标定义为项目的pv)

加密 && 压缩

在数据上报之前,会对数据进行压缩

/**
 * @name arrayBufferToBase64 arrayBuffer转base64
 * @param {ArrayBuffer} buffer ArrayBuffer对象
 * @returns {String} Base64字符串
 */
export const arrayBufferToBase64 = (buffer) => {
  let binary = '';
  const bytes = new Uint8Array(buffer);
  for (let len = bytes.byteLength, i = 0; i < len; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return window.btoa(binary);
};

// gzip压缩
const gzip = pako.gzip(formatStr);

// 加密
const encrypt = new JSEncrypt();
const timestamp = new Date().getTime();
encrypt.setPublicKey(PUBLIC_KEY);
const encrypted = encrypt.encrypt(`${APM_KEY}___${timestamp}`);

const reqUrl = `${uploadHost}${useImg ? UPLOAD_BEACON_API : UPLOAD_API}?key=${APM_KEY}&sign=${encrypted}&timestamp=${timestamp}`;
const reqData = {
  data: arrayBufferToBase64(gzip),
  appv: appV,
  systemtype: systemType,
  appname: appName,
  treatyversion: treatyVersion,
};

数据存储

我们的数据存储使用的是ClickHouse,ClickHouse 是一个开源的列式数据库管理系统,专为实时分析而设计。它针对分析场景提供了高性能、高并发的数据存储和查询能力,尤其擅长处理大规模数据集的聚合查询。(由于这个由专门的中间件团队维护,我们不是特别了解,也造成了后期我们功能迭代依赖于外部的大坑)

问题定位

目前线上的运行的代码都是经过压缩、混淆之后的乱码。我们如何能够在发现问题后,快速定位到源码的位置?Source-map。

实现原理

虽然我们不会把build后的souce-map直接上传到生产服务器上,但是我们任然可以保留一份souce-map到APM的服务器上。

通过错误中的的行列号信息报错的文件地址,就可以定位到源码的错误位置。

文件上传的时机

我们公司是通过在构建流程中加入插件的形式完成的。当打包流程完成的时候,将打包出来的source-map文件压缩后上传到APM服务器

if (env === 'production' && process.env.NODE_ENV === 'production') {
  const MAP_PATTERN = './build/**/*.map';
  // 将 build 目录下的.map文件打成zip包
  const zipPath = path.resolve(BUILD_PATH, `${root}.zip`);
  const files = glob.sync(MAP_PATTERN);
  const {
    action,
    name,
    data,
  } = this.options;

  // 上传打包后的 map文件
  this.upload({
    files,
    zipPath,
    requestOpts: {
      action,
      name,
      data: typeof data === 'function' ? data(root) : data,
    },
  }).then(() => {
    // 上传完成之后删除.zip包和.map文件
    this.deleteSourceMaps(zipPath);
    success('SourceMap同步成功');
    callback();
  }).catch((err) => {
    error(err);
    // 上传出错也需要 删除.zip包和.map文件
    this.deleteSourceMaps(zipPath);
    callback();
  });
}
  • 上传完成之后,不管成功失败,都需要删除.map和 .zip文件,以免上传到生产服务器

错误解析

现在我们已经有了sourmap文件,那么只需要具体的错误信息,就可以定位到源代码的位置了。APM后端服务中提供了一个接口来做这件事情。主要流程

  1. 拿到报错的原始信息
  2. 将错误信息解析成错误栈列表
  3. 通过source-map这个npm包获取源代码的位置
  4. 截取错误位置的部分上下文,返回给前端

我们的原始错误信息可能长这样:(当然还有PromiseError的情况)

TypeError: undefined is not iterable (cannot read property Symbol(Symbol.iterator))
    at https://cdnmobile.xxx.cn/xxx/main.6ed01ed7.js:1:207065
    at y (https://cdnmobile.xxx.cn/xxx/main.6ed01ed7.js:1:207127)
    at FETCH_COUPON_LIST (https://xx.xxx.cn/xx/main.6ed01ed7.js:1:207795)
    at Object.FETCH_COUPON_LIST (https://cdnmobile.xxx.cn/coupon-mall/vendors~main.39e6ea90.js:27:19229)
    at https://xxx.xxx.cn/xxx/vendors~main.39e6ea90.js:27:19965
    at https://xxx.xxx.cn/xxx/vendors~main.39e6ea90.js:27:32252
    at https://xxx.xxx.cn/xxx/vendors~main.39e6ea90.js:27:32252
    at v (https://xxx.xxx.cn/xxx/vendors~main.39e6ea90.js:27:30187)
    at https://xxx.xxx.cn/xxx/vendors~main.39e6ea90.js:44:2461
    at dispatch (https://xxx.xxx.cn/xxx/xxx.39e6ea90.js:27:33413)

封装了一个parsePromiseStacks方法用来解析错误栈,解析完的格式如下

const errorStacks = parsePromiseStacks(msg);
// [{...},{...},{...},{...},{...}]
// item: {
//   "stack":"https://xxx/xxx/main.6ed01ed7.js:1:208706",
//   "colno":208706,
//   "lineno":1,
//    "filename":"https://xxx/xxx/main.6ed01ed7.js"
// }

循环遍历errorStacks根据行列信息查看在源代码中的信息

// 查找sourcemap
function lookupSourceMap(mapFile, line, column) {
  // ...
  return new Promise(function (resolve) {
    // mapFile为 .map 文件
    fs.readFile(mapFile, async function (err, data) {
      // ... fileContent 
      let consumer = await new sourceMap.SourceMapConsumer(fileContent);
      const lookup = {
        line: parseInt(line, 10),
        column: parseInt(column, 10),
      };
      const result = consumer.originalPositionFor(lookup);
      // result:
      // {
      //   column: 39,
      //   line: 80,
      //   name: "waitPayCount",
      //   source: "webpack:///src/reducers/couponCenter.js",
      //   sourcesContent: "import { combineReducers } from 'redux';\nimport { buildReduce } from 'util';\n\n//",
      // }
      resolve(result);
    });
  });
}

虽然这个时候定位到了错误,但是我们需要一些context,能对错误信息一目了然

const getErrorCode = (result, lineLen) => {
  const { file, row, column, source, stack } = result;
  // 上下文的长度
  const ll = lineLen || 6;
  if (file && column !== null && row !== null) {
    const lines = result.file.split('\n');
    const hafLen = Math.floor(ll / 2);
    const len = lines.length - 1;
    const start = row - hafLen >= 0 ? row - hafLen : 0;
    const end = start + ll - 1 >= len ? len : start + ll - 1; // 最多展示6行
    const newLines = [];
    for (let i = start; i <= end; i++) {
      newLines.push(`${i + 1}.  ${lines[i]}`);
    }
    return {
      row,
      column,
      source,
      stack,
      errorCode: newLines,
    };
  }
  return { stack };
};

错误解析完成后,返回给前端,在APM管理页面上展示出来

定时清理

由于每次发布上线都会上传一下source-map,那么时间长了必然会导致服务器的磁盘被打满,所以需要定时清理无用的source-map文件,清理策略:

  1. 每天早上9:50的时候,将七天前的source-map文件清除(因为错误日志最多保留七天)
  2. 清除的时候有个条件:至少保留一个最新版本的source-map文件(防止七天内没有发布)

异常告警

告警规则

APM中针对脚本异常、资源加载异常、请求异常、满页面、白屏异常,可以配置对应的告警规则

静态规则

每10分钟的告警次数超过x个,就会告警

动态规则

告警的个数相对于前10分钟,有轻微增加、增加、较明显增加、明显增加的时候,会告警,对应的比例如下

const RULEOPTION = {
  3.36: '明显增加',
  2.02: '较明显增加',
  1.47: '增加',
  1.282: '轻微增加',
};
// 触发告警的代码
try {
  // 动态规则
  if (item.ruleType === 1) {
    // 最近10分钟的报错数
    const dataList = await getCommonList(body);
    // 前10分钟的报错数
    const lastDataList = await getCommonList(lastBody);
    const exceptionNum = dataList.length;
    const lastExceptionNum = lastDataList.length;
    let result = Infinity;
    let isWarning = true;
    if (exceptionNum === 0) {
      isWarning = false;
    } else if (exceptionNum !== 0 && lastExceptionNum !== 0) {
      result = exceptionNum / lastExceptionNum;
    }
    // 如果比例大于 RULEOPTION 中的比例,那么就会触发告警
    if (isWarning && item.targetValue < result) {
      // 调用钉钉hooks
      alarm(item, { exceptionNum, targetValue: item.targetValue });
    }
  // 静态规则
  } else {
    const dataList = await getCommonList(body);
    const exceptionNum = dataList.length;
    if (item.targetValue < exceptionNum) {
      // 调用钉钉hooks
      alarm(item, { exceptionNum });
    }
  }
} catch (e) {
  console.log(e);
}

告警方式

APM服务中,会跑一个定时任务,每隔10分钟会将数据库中的所有规则拿出来跑一下,如果发现有命中了规则的,就会调用钉钉机器人hook,发送钉钉告警

// 钉钉机器人告警信息
const dingRobot = {
  // 消息类型markdown
  msgtype: 'markdown',
  markdown: {
    // 标题
    title: 'APM告警',
    // 内容
    text: `#### APM告警提醒
      \n 应用名称:${appName}
      \n 告警类型:${EVENTTYPE[eventType]}
      \n 告警内容:${EVENTTYPE[eventType]}指标超过阈值,${alarmMsg}
      \n 告警时间:${currentTime}
      ${atStr ? `\n 应用负责人:${atStr}` : ''}
      \n [查看详情](跳转链接的地址)`,
  },
  at: {
    atMobiles,
  },
};
let data;
const params = {
  method: 'post',
  url,
  data: dingRobot,
};
// !!! 这个url是在钉钉群里面生成的
if (url) {
  data = await request(params);
}

dingding告警配置文档

数据看板

这里简单说一下功能点,就不上图了

健康分计算

对于每个项目,会根据js报错率、资源异常率、请求异常率、LCP合格率、白屏率等算出一个健康分

排行榜

以项目维度对所有的项目根据健康分进行排序

英雄榜

负责人的维度,对所有人的健康分平均分进行通晒,排名

需要完善的地方

  1. 有很多异常报错。是由于用户网络较差导致的,如何去监控到用户的弱网情况,并且过滤掉这种报错,降低误报率。
  2. sendBeacon中传输的数据量超过了限制的情况,可能需要处理一下。