# 这三年沉淀的前端错误监控系统,一篇文章讲透给你

https://mp.weixin.qq.com/s/NY72_cRGAPsex78YwVF_hg (opens new window)

# 痛点

某⼀天产品:xxx ⼴告主反馈我们的页面注册不了!又⼀天运营:这个活动在 xxx 媒体上挂掉了!

在我司线上运行的是近亿级别的广告页面,这样线上如果裸奔,出现了什么问题不知道,后置在业务端发现,被业务方询问,这种场景很尴尬。

# 选择

公司存在四个事业部,而每个事业部不下于 3 个项目,这里至少 12 个项目,这里作为伏笔,业务线多。

我们是选择自己做呢,还是选第三方的呢。我们比较一项几款常见第三方。

  • Fundebug:付费版 159 元/月起,数据存在第三方,而数据自我保存需要 30 万/年。还是很贵的。
  • FrontJS,FrontJS 高级版 899/月,专业版是 2999/月。
  • Sentry,80 美金/月。

以 Sentry 为计费,对这 12 个项目计算一下。12 个项目一年将近 10 万。而大致估算过需要 2 人 1.5 月即 90 人日,能完成 MVP 版本,按每人 1.5 万工资/月计算,总共花费 4.5 万,而且是一劳永逸的。

因此从成本角度我们会选择自研,但除了成本外,还有其他原因。例如我们会基于这套系统做一些自定义功能,与公司权限用户系统打通,再针对用户进行 Todo 管理,对用户进行错误排行等。

还有基于业务数据的安全,我们希望自我搭建一个系统。

所以从成本、安全、扩展性角度,我们选择了自己研发。

# 产品设计

我们要什么样的一个产品呢,根据第一性原理,解决关键问题“怎么定位问题”。通过 5W1H 法我们来分析,我们想要知道些什么信息呢?

# 错误信息

其实错误监控说简单就一句话可以描述,搜集页面错误,进行上报,然后对症分析。

按照 5W1H 法则进行分析这句话,可以发现有几项需要我们关注。

  1. What,发⽣了什么错误:逻辑错误、数据错误、⽹络错误、语法错误等。
  2. When,出现的时间段,如时间戳。
  3. Who,影响了多少用户,包括报错事件数、IP、设备信息。
  4. Where,出现的页面是哪些,包括页面、广告位(我司)、媒体(我司)。
  5. Why,错误的原因是为什么,包括错误堆栈、⾏列、SourceMap。
  6. How,怎么定位解决问题,我们还需要收集系统等信息。

# 架构层次

首先我们需要梳理下,我们需要一些哪些功能。

那我们怎么得到上面的信息进行最终错误的定位呢。

首先我们肯定需要对错误进行搜集,然后用户设备页面端的错误我们怎么才能感知到呢,这就需要进行上报。那么第一层就展现出来了,我们需要一个搜集上报端。

那怎么才能进行上报呢,和后端协作那么久,肯定知道的吧 🙃 ,你需要一个接口。那就需要一个服务器来进行对于上报的错误进行采集,对于错误进行筛选聚合。那么第二层也知道了啊,我们需要一个采集聚合端。

我们搜集到了我们足够的物料信息了,那接下来要怎么用起来呢,我们需要把它们按照我们的规则进行整理。如果每次又是通过写类 SQL 进行整理查询效率会很低,因此我们需要一个可视化的平台进行展示。因此有了第三层,可视化分析端。

感觉好像做完啦,想必大家都这么想,一个错误监控平台做完了,🙅 。如果是这样你会发现一个现象,每次上线和上线后一段时间,开发同学都一直盯着屏幕看,这是在干嘛,人形眼动观察者模式吗。因此我们需要通过代码去解决,自然而然,第四层,监控告警端应运而生。

所以请大声说出来我们需要什么 🙈 ,搜集上报端,采集聚合端,可视分析端,监控告警端。

# 系统设计

如函数一样,定义好每个环节的输入和输出,且核心需要处理的功能。

下面我们看看上述所说的四个端怎么去实现呢。

# 搜集上报端(SDK)

这个环节主要输入是所有错误,输出是捕获上报错误。核心是处理不同类型错误的搜集工作。其他是一些非核心但必要的工作。

# 错误类型

先看看我们需要处理哪些错误类型。

常见 JS 执行错误

  1. SyntaxError

    解析时发生语法错误

// 控制台运行
const xx,

window.onerror 捕获不到 SyntxError,一般 SyntaxError 在构建阶段,甚至本地开发阶段就会被发现。

  1. TypeError

    值不是所期待的类型

// 控制台运行
const person = void 0;
person.name;
  1. ReferenceError

    引用未声明的变量

// 控制台运行
nodefined;
  1. RangeError

    当一个值不在其所允许的范围或者集合中

(function fn() {
  fn();
})();

网络错误

  1. ResourceError

    资源加载错误

new Image().src = "/remote/image/notdeinfed.png";
  1. HttpError

    Http 请求错误

// 控制台运行
fetch("/remote/notdefined", {});

# 搜集错误

所有起因来源于错误,那我们如何进行错误捕获。

try/catch

能捕获常规运行时错误,语法错误和异步错误不行

// 常规运行时错误,可以捕获 ✅
try {
  console.log(notdefined);
} catch(e) {
  console.log('捕获到异常:', e);
}

// 语法错误,不能捕获 ❌
try {
  const notdefined,
} catch(e) {
  console.log('捕获到异常:', e);
}

// 异步错误,不能捕获 ❌
try {
  setTimeout(() => {
    console.log(notdefined);
  }, 0)
} catch(e) {
  console.log('捕获到异常:',e);
}

try/catch 有它细致处理的优势,但缺点也比较明显。

window.onerror

pure js 错误收集,window.onerror,当 JS 运行时错误发生时,window 会触发一个 ErrorEvent 接口的 error 事件。

/**
 * @param {String}  message    错误信息
 * @param {String}  source    出错文件
 * @param {Number}  lineno    行号
 * @param {Number}  colno    列号
 * @param {Object}  error  Error对象
 */

window.onerror = function (message, source, lineno, colno, error) {
  console.log("捕获到异常:", { message, source, lineno, colno, error });
};

先验证下几个错误是否可以捕获。

// 常规运行时错误,可以捕获 ✅
window.onerror = function(message, source, lineno, colno, error) {
  console.log('捕获到异常:',{message, source, lineno, colno, error});
}
console.log(notdefined);

// 语法错误,不能捕获 ❌
window.onerror = function(message, source, lineno, colno, error) {
  console.log('捕获到异常:',{message, source, lineno, colno, error});
}
const notdefined,

// 异步错误,可以捕获 ✅
window.onerror = function(message, source, lineno, colno, error) {
  console.log('捕获到异常:',{message, source, lineno, colno, error});
}
setTimeout(() => {
  console.log(notdefined);
}, 0)

// 资源错误,不能捕获 ❌
<script>
  window.onerror = function(message, source, lineno, colno, error) {
  console.log('捕获到异常:',{message, source, lineno, colno, error});
  return true;
}
</script>
<img src="https://yun.tuia.cn/image/kkk.png">

window.onerror 不能捕获资源错误怎么办?

window.addEventListener

当一项资源(如图片或脚本)加载失败,加载资源的元素会触发一个 Event 接口的 error 事件,这些 error 事件不会向上冒泡到 window,但能被捕获。而 window.onerror 不能监测捕获。

// 图片、script、css加载错误,都能被捕获 ✅
<script>
  window.addEventListener(
    "error",
    (error) => {
      console.log("捕获到异常:", error);
    },
    true
  );
</script>
<img src="https://yun.tuia.cn/image/kkk.png" />
<script src="https://yun.tuia.cn/foundnull.js"></script>
<link href="https://yun.tuia.cn/foundnull.css" rel="stylesheet" />

// new Image错误,不能捕获 ❌
<script>
  window.addEventListener(
    "error",
    (error) => {
      console.log("捕获到异常:", error);
    },
    true
  );
</script>
<script>
  new Image().src = "https://yun.tuia.cn/image/lll.png";
</script>

// fetch错误,不能捕获 ❌
<script>
  window.addEventListener(
    "error",
    (error) => {
      console.log("捕获到异常:", error);
    },
    true
  );
</script>
<script>
  fetch("https://tuia.cn/test");
</script>

new Image 运用的比较少,可以单独自己处理自己的错误。

但通用的 fetch 怎么办呢,fetch 返回 Promise,但 Promise 的错误不能被捕获,怎么办呢?

Promise 错误

  1. 普通 Promise 错误

    try/catch 不能捕获 Promise 中的错误

// try/catch 不能处理 JSON.parse 的错误,因为它在 Promise 中
try {
  new Promise((resolve, reject) => {
    JSON.parse("");
    resolve();
  });
} catch (err) {
  console.error("in try catch", err);
}

// 需要使用catch方法
new Promise((resolve, reject) => {
  JSON.parse("");
  resolve();
}).catch((err) => {
  console.log("in catch fn", err);
});
  1. async 错误

    try/catch 不能捕获 async 包裹的错误

const getJSON = async () => {
  throw new Error("inner error");
};

// 通过try/catch处理
const makeRequest = async () => {
  try {
    // 捕获不到
    JSON.parse(getJSON());
  } catch (err) {
    console.log("outer", err);
  }
};

try {
  // try/catch不到
  makeRequest();
} catch (err) {
  console.error("in try catch", err);
}

try {
  // 需要await,才能捕获到
  await makeRequest();
} catch (err) {
  console.error("in try catch", err);
}
  1. import chunk 错误

    import 其实返回的也是一个 promise,因此使用如下两种方式捕获错误

// Promise catch方法
import(/* webpackChunkName: "incentive" */ "./index")
  .then((module) => {
    module.default();
  })
  .catch((err) => {
    console.error("in catch fn", err);
  });

// await 方法,try catch
try {
  const module = await import(/* webpackChunkName: "incentive" */ "./index");
  module.default();
} catch (err) {
  console.error("in try catch", err);
}

小结:全局捕获 Promise 中的错误

以上三种其实归结为 Promise 类型错误,可以通过 unhandledrejection 捕获

// 全局统一处理Promise
window.addEventListener("unhandledrejection", function (e) {
  console.log("捕获到异常:", e);
});
fetch("https://tuia.cn/test");

为了防止有漏掉的 Promise 异常,可通过 unhandledrejection 用来全局监听 Uncaught Promise Error。

Vue 错误

由于 Vue 会捕获所有 Vue 单文件组件或者 Vue.extend 继承的代码,所以在 Vue 里面出现的错误,并不会直接被 window.onerror 捕获,而是会抛给 Vue.config.errorHandler。

/**
 * 全局捕获Vue错误,直接扔出给onerror处理
 */
Vue.config.errorHandler = function (err) {
  setTimeout(() => {
    throw err;
  });
};

React 错误

react 通过 componentDidCatch,声明一个错误边界的组件

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // 更新 state 使下一次渲染能够显示降级后的 UI
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // 你同样可以将错误日志上报给服务器
    logErrorToMyService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // 你可以自定义降级后的 UI 并渲染
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children;
  }
}

class App extends React.Component {
  render() {
    return (
      <ErrorBoundary>
        <MyWidget />
      </ErrorBoundary>
    );
  }
}

但 error boundaries 并不会捕捉以下错误:React 事件处理,异步代码,error boundaries 自己抛出的错误。

# 跨域问题

一般情况,如果出现 Script error 这样的错误,基本上可以确定是出现了跨域问题。

如果当前投放页面和云端 JS 所在不同域名,如果云端 JS 出现错误,window.onerror 会出现 Script Error。通过以下两种方法能给予解决。

  • 后端配置 Access-Control-Allow-Origin、前端 script 加 crossorigin。
<script src="http://yun.tuia.cn/test.js" crossorigin></script>;

const script = document.createElement("script");
script.crossOrigin = "anonymous";
script.src = "http://yun.tuia.cn/test.js";
document.body.appendChild(script);
  • 如果不能修改服务端的请求头,可以考虑通过使用 try/catch 绕过,将错误抛出。
<!DOCTYPE html>
<html>
  <head>
    <title>Test page in http://test.com</title>
  </head>
  <body>
    <script src="https://yun.dui88.com/tuia/cdn/remote/testerror.js"></script>
    <script>
      window.onerror = function (message, url, line, column, error) {
        console.log(message, url, line, column, error);
      };

      try {
        foo(); // 调用testerror.js中定义的foo方法
      } catch (e) {
        throw e;
      }
    </script>
  </body>
</html>

会发现如果不加 try catch,console.log 就会打印 script error。加上 try catch 就能捕获到。

我们捋一下场景,一般调用远端 js,有下列三种常见情况。

  • 调用远端 JS 的方法出错
  • 远端 JS 内部的事件出问题
  • 要么在 setTimeout 等回调内出错

调用方法场景

可以通过封装一个函数,能装饰原方法,使得其能被 try/catch。

<!DOCTYPE html>
<html>
  <head>
    <title>Test page in http://test.com</title>
  </head>
  <body>
    <script src="https://yun.dui88.com/tuia/cdn/remote/testerror.js"></script>
    <script>
      window.onerror = function (message, url, line, column, error) {
        console.log(message, url, line, column, error);
      };

      function wrapErrors(fn) {
        // don't wrap function more than once
        if (!fn.__wrapped__) {
          fn.__wrapped__ = function () {
            try {
              return fn.apply(this, arguments);
            } catch (e) {
              throw e; // re-throw the error
            }
          };
        }

        return fn.__wrapped__;
      }

      wrapErrors(foo)();
    </script>
  </body>
</html>

大家可以尝试去掉 wrapErrors 感受下。

事件场景

可以劫持原生方法。

<!DOCTYPE html>
<html>
  <head>
    <title>Test page in http://test.com</title>
  </head>
  <body>
    <script>
      const originAddEventListener = EventTarget.prototype.addEventListener;
      EventTarget.prototype.addEventListener = function (
        type,
        listener,
        options
      ) {
        const wrappedListener = function (...args) {
          try {
            return listener.apply(this, args);
          } catch (err) {
            throw err;
          }
        };
        return originAddEventListener.call(
          this,
          type,
          wrappedListener,
          options
        );
      };
    </script>
    <div style="height: 9999px;">http://test.com</div>
    <script src="https://yun.dui88.com/tuia/cdn/remote/error_scroll.js"></script>
    <script>
      window.onerror = function (message, url, line, column, error) {
        console.log(message, url, line, column, error);
      };
    </script>
  </body>
</html>

大家可以尝试去掉封装 EventTarget.prototype.addEventListener 的那段代码,感受下。

# 上报接口

为什么不能直接用 GET/POST/HEAD 请求接口进行上报?

这个比较容易想到原因。一般而言,打点域名都不是当前域名,所以所有的接口请求都会构成跨域。

为什么不能用请求其他的文件资源(js/css/ttf)的方式进行上报?

创建资源节点后只有将对象注入到浏览器 DOM 树后,浏览器才会实际发送资源请求。而且载入 js/css 资源还会阻塞页面渲染,影响用户体验。

构造图片打点不仅不用插入 DOM,只要在 js 中 new 出 Image 对象就能发起请求,而且还没有阻塞问题,在没有 js 的浏览器环境中也能通过 img 标签正常打点。

使用 new Image 进行接口上报。最后一个问题,同样都是图片,上报时选用了 1x1 的透明 GIF,而不是其他的 PNG/JEPG/BMP 文件。

首先,1x1 像素是最小的合法图片。而且,因为是通过图片打点,所以图片最好是透明的,这样一来不会影响页面本身展示效果,二者表示图片透明只要使用一个二进制位标记图片是透明色即可,不用存储色彩空间数据,可以节约体积。因为需要透明色,所以可以直接排除 JEPG。

同样的响应,GIF 可以比 BMP 节约 41%的流量,比 PNG 节约 35%的流量。GIF 才是最佳选择。

  • 可以进行跨域
  • 不会携带 cookie
  • 不需要等待服务器返回数据

使用 1*1 的 gif[1]

# 非阻塞加载

尽量避免 SDK 的 js 资源加载影响。

通过先把 window.onerror 的错误记录进行缓存,然后异步进行 SDK 的加载,再在 SDK 里面处理错误上报。

<!DOCTYPE html>
<html lang="en">
<head>
    <script>
        (function(w) {
            w._error_storage_ = [];
            function errorhandler(){
                // 用于记录当前的错误
                w._error_storage_&&w._error_storage_.push([].slice.call(arguments));
            }
            w.addEventListener && w.addEventListener("error", errorhandler, true);
            var times = 3,
            appendScript = function appendScript() {
                var sc = document.createElement("script");
                sc.async = !0,
                sc.src = './build/skyeye.js',  // 取决于你存放的位置
                sc.crossOrigin = "anonymous",
                sc.onerror = function() {
                    times--,
                    times > 0 && setTimeout(appendScript, 1500)
                },
                document.head && document.head.appendChild(sc);
            };
            setTimeout(appendScript, 1500);
        })(window);
    </script>
</head>
<body>
    <h1>这是一个测试页面(new)</h1>
</body>
</html>

# 采集聚合端(日志服务器)

这个环节,输入是借口接收到的错误记录,输出是有效的数据入库。核心功能需要对数据进行清洗,顺带解决了过多的服务压力。另一个核心功能是对数据进行入库。

总体流程可以看为错误标识 -> 错误过滤 -> 错误接收 -> 错误存储。

# 错误标识(SDK 配合)

聚合之前,我们需要有不同维度标识错误的能力,可以理解为定位单个错误条目,单个错误事件的能力。

**单个错误条目 **

通过 date 和随机值生成一条对应的错误条目 id。

const errorKey = `${+new Date()}@${randomString(8)}`;

function randomString(len) {
  len = len || 32;
  let chars = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678";
  let maxPos = chars.length;
  let pwd = "";
  for (let i = 0; i < len; i++) {
    pwd += chars.charAt(Math.floor(Math.random() * maxPos));
  }
  return pwd;
}

单个错误事件

首先需要有定位同个错误事件(不同用户,发生相同错误类型、错误信息)的能力。

通过 message、colno 与 lineno 进行相加计算阿斯克码值,可以生成错误的 errorKey。

const eventKey = compressString(
  String(e.message),
  String(e.colno) + String(e.lineno)
);

function compressString(str, key) {
  let chars = "ABCDEFGHJKMNPQRSTWXYZ";
  if (!str || !key) {
    return "null";
  }
  let n = 0,
    m = 0;
  for (let i = 0; i < str.length; i++) {
    n += str[i].charCodeAt();
  }
  for (let j = 0; j < key.length; j++) {
    m += key[j].charCodeAt();
  }
  let num =
    n +
    "" +
    key[key.length - 1].charCodeAt() +
    m +
    str[str.length - 1].charCodeAt();
  if (num) {
    num = num + chars[num[num.length - 1]];
  }
  return num;
}

如下图,一个错误事件(事件列表),下属每条即为实际的错误条目。图片

# 错误过滤(SDK 配合)

域名过滤

过滤本页面 script error,可能被 webview 插入其他 js。

我们只关心自己的远端 JS 问题,因此做了根据本公司域名进行过滤。

// 伪代码
if (!e.filename || !e.filename.match(/^(http|https):\/\/yun./)) return true;

重复上报

怎么避免重复的数据上报?根据 errorKey 来进行缓存,重复的错误避免上报的次数超过阈值。

// 伪代码

const localStorage = window.localStorage;
const TIMES = 6; // 缓存条数

export function setItem(key, repeat) {
  if (!key) {
    key = "unknow";
  }

  if (has(key)) {
    const value = getItem(key);

    // 核心代码,超过条数,跳出
    if (value >= repeat) {
      return true;
    }
    storeStorage[key] = {
      value: value + 1,
      time: Date.now(),
    };
  } else {
    storeStorage[key] = {
      value: 1,
      time: Date.now(),
    };
  }
  return false;
}

# 错误接收

在处理接收接口的时候,注意流量的控制,这也是后端开发需要投入最多精力的地方,处理高并发的流量。

错误记录

接收端使用 Koa,简单的实现了接收及打印到磁盘。

// 伪代码

module.exports = async ctx => {
  const { query } = ctx.request;

  // 对于字段进行简单check
  check([ 'mobile', 'network', 'ip', 'system', 'ua', ......], query);

  ctx.type = 'application/json';
  ctx.body = { code: '1', msg: '数据上报成功' };

  // 进行日志记录到磁盘的代码,根据自己的日志库选择
};

削峰机制

比如每秒设置 2000 的阈值,然后根据请求量减少上限,定时重置上限。

// 伪代码

// 1000ms
const TICK = 1000;
// 1秒上限为2000
const MAX_LIMIT = 2000;
// 每台服务器请求上限值
let maxLimit = MAX_LIMIT;

/**
 * 启动重置函数
 */
const task = () => {
  setTimeout(() => {
    maxLimit = MAX_LIMIT;
    task();
  }, TICK);
};
task();

const check = () => {
  if (maxLimit <= 0) {
    throw new Error("超过上报次数");
  }
  maxLimit--;
  // 执行业务代码。。。
};

采样处理

超过阈值,还可以进行采样收集。

// 只采集 20%
if (Math.random() < 0.2) {
  collect(data); // 记录错误信息
}

# 错误存储

对于打印在了磁盘的日志,我们怎么样才能对于其进行聚合呢,这里得考虑使用存储方案。

一般选择了存储方案后,设置好配置,存储方案就可以通过磁盘定时周期性的获取数据。因此我们需要选择一款存储方案。

对于存储方案,我们对比了日常常见方案,阿里云日志服务 - Log Service(SLS)、ELK(Elastic、Logstash、Kibana)、Hadoop/Hive(将数据存储在 Hadoop,利用 Hive 进行查询) 类方案的对比。

从以下方面进行了对比,最终选择了 Log Service,主要考虑为无需搭建,成本低,查询功能满足。

功能项 ELK 类系统 Hadoop + Hive 日志服务
日志延时 1~60 秒 几分钟~数小时 实时
查询延时 小于 1 秒 分钟级 小于 1 秒
查询能力
扩展性 提前预备机器 提前预备机器 秒级 10 倍扩容
成本 较高 较低 很低

日志延时:日志产生后,多久可查询。查询延时:单位时间扫描数据量。查询能力:关键词查询、条件组合查询、模糊查询、数值比较、上下文查询。扩展性:快速应对百倍流量上涨。成本:每 GB 费用。

具体 API 使用,可查看日志服务[2]。

# 可视分析端(可视化平台)

这个环节,输入是借口接收到的错误记录,输出是有效的数据入库。核心功能需要对数据进行清洗,顺带解决了过多的服务压力。另一个核心功能是对数据进行入库。

# 主功能

这部分主要是产品功能的合理设计,做到小而美,具体的怎么聚合,参考阿里云 SLS 就可以。

  1. 首页图表,可选 1 天、4 小时、1 小时等等,聚合错误数,根据 1 天切分 24 份来聚合。
  2. 首页列表,聚合选中时间内的数据,展示错误文件、错误 key、事件数、错误类型、时间、错误信息。
  3. 错误详情,事件列表、基本信息、设备信息、设备占比图表(见上面事件列表的图)。

图片image.png

# 排行榜

刚开始做了待处理错误列表、我的错误列表、已解决列表,错误与人没有绑定关系,过于依赖人为主动,需要每个人主动到平台上处理,效果不佳。

后面通过错误作者排行榜,通过钉钉日报来提醒对应人员处理。紧急错误,通过实时告警来责任到人,后面告警会说。

具体原理:

  • webpack 打包通过 git 命令把作者和作者邮箱、时间打包在头部。
  • 在可视化服务中,去请求对应的报错 url 匹配到对应作者,返回给展示端。

图片image.png

# SourceMap

利用 webpack 的 hidden-source-map 构建。与 source-map 相比少了末尾的注释,但 output 目录下的 index.js.map 没有少。线上环境避免 source-map 泄露。

webpackJsonp([1],[
  function(e,t,i){...},
  function(e,t,i){...},
  function(e,t,i){...},
  function(e,t,i){...},
  ...
])
// 这里没有生成source-map的链接地址

根据报错文件的 url,根据团队内部约定好的目录和规则,定位之前打包上传的 sourceMap 地址。

const sourcemapUrl = "xxxfolder/" + url + "xxxHash" + ".map";

获取上报的 line、column、source,利用第三方库 sourceMap 进行定位。

const sourceMap = require("source-map");

// 根据行数获取源文件行数
const getPosition = async (map, rolno, colno) => {
  const consumer = await new sourceMap.SourceMapConsumer(map);

  const position = consumer.originalPositionFor({
    line: rolno,
    column: colno,
  });

  position.content = consumer.sourceContentFor(position.source);

  return position;
};

图片感兴趣 SourceMap 原理的,可以继续深入,SourceMap 与前端异常监控 (opens new window)

# 错误报警

# 报警设置

  1. 每条业务线设置自己的阈值、错误时间跨度,报警轮询间隔
  2. 通过钉钉 hook 报警到对应的群
  3. 通过日报形式报出错误作者排行榜

图片image.png

# ○ 四、扩展

# 行为搜集

通过搜集用户的操作,可以明显发现错误为什么产生。图片

# 分类

  • UI 行为:点击、滚动、聚焦/失焦、长按
  • 浏览器行为:请求、前进/后退、跳转、新开页面、关闭
  • 控制台行为:log、warn、error

# 搜集方式

  1. 点击行为

使用 addEventListener 监听全局上的 click 事件,将事件和 DOM 元素名字收集。与错误信息一起上报。

  1. 发送请求

监听 XMLHttpRequest 的 onreadystatechange 回调函数

  1. 页面跳转

监听 window.onpopstate,页面进行跳转时会触发。

  1. 控制台行为

重写 console 对象的 info 等方法。

有兴趣可以参考行为监控[3]。

# 遇到的问题

由于涉及到一些隐私,下述会做脱敏处理。

# 空日志问题

上线灰度运行后,我们发现 SLS 日志存在一些空日志 😢 ,🦢,这是发生了啥?

首先我们回忆下这个链路上有哪些环节可能存在问题。排查链路,SLS 采集环节之前有磁盘日志收集,服务端接收,SDK 上报,那我们依次排查。

图片往前一步,发现磁盘日志就已经存在空日志,那剩下就得看一下接收端、SDK 端。

开始利用控制变量法,先在 SDK 端进行空判断,防止空日志上报。结果:发现无效 😅。

再继续对 Node 接收端处理,对接收到的数据进行判空,如果为空不进行日志打印,结果:依然无效 😳。

所以开始定位是不是日志打印本身出了什么问题?研究了下日志第三方日志库的 API,进行了各种尝试,发现依旧没用,我脸黑了 🌚。

什么情况,“遇事不决”看源码。排查下日志库源码存在什么问题。对于源码的主调用流程走了一遍,并没有发现什么问题,一头雾水 🙃。

整个代码逻辑很正常,这让我们开始怀疑难道是数据的问题,于是开始缩减上报的字段,最终定义为了一个字段。发现上线后没有问题了 😢。

难道是有些字段存储的数据过长导致的?但从代码逻辑、流程日志中并没有反应这个错误的可能性。

因此我们利用二分法,二分地增加字段,最终定位到了某个字段。如果存在某个字段上报就会出现问题。这很出乎人的意料。

我们再想了下链路,除了日志库,其他代码基本都是我们自己的逻辑,所以对日志库进行了排查,怀疑其对某个字段做了什么处理。

于是通过搜索,定位到了日志库在仆从模式(可以了解下 Node 的主从模式)下会使用某个字段来表意,导致和我们上报的字段冲突,因此丢失了 🤪。

# 日志丢失问题

解决了上个问题,开心了,一股成就感涌上心头。但马上就被当头一棒,我发现我高兴的太早了 🤮。

团队的某同学在本地测试的时候,由于玩的很开心,一直去刷新页面去上报当前页面的错误。但他发现本地上报的条数和实际日志服务里的条数对不上,日志服务里的少了很多。

由于之前自身刚毕业时候做过 2 年多后端开发,对于 IO 操作丢失数据还是有点敏感。直觉上就感觉可能是多进程方向的问题。怀疑是多进程导致的文件死锁问题。

那我们去掉多线程,通过单线程,我们去重复原先复现问题的步骤。发现没有遗漏 🤭。

我们发现能进行配置 Cluster(主从模式)的地方有两处,日志库和部署工具。

观察日志库默认使用的主从进程模式,而部署工具没有主从模式的概念,势必会导致写入 IO 的死锁问题,导致日志丢失。于是在想社区有没有可以有解决此问题的第三方支持。

然后通过谷歌搜索,很快就找到了对应的第三方库,它能提供主人进程和仆从进程之间的消息沟通。原理是主人进程负责所有消息写入 log,而仆从进程通过消息传递给主人进程。

# 推荐阅读及引用

处理异常

如何优雅处理前端异常?[4]

source-map

SourceMap 与前端异常监控 (opens new window)

React 错误

React,优雅的捕获异常 (opens new window)

Script Error

Capture and report JavaScript errors with window.onerror | Product Blog • Sentry[5] > [What the heck is "Script error"? | Product Blog • Sentry](https://blog.sentry.io/2016/05/17/what-is-script-error "What the heck is "Script error"? | Product Blog • Sentry")

整体

前端搞监控|Allan - 如何实现一套多端错误监控平台[6] > 一步一步搭建前端监控系统:JS 错误监控篇[7] > 撸一个前端监控系统[8]

之前开放日自己演讲的

[1]使用 1*1 的 gif: https://g.yuque.com/zaotalk/posts/mxx4cb#QUH0x

[2]日志服务: https://www.aliyun.com/product/sls

[3]行为监控: https://www.yuque.com/zaotalk/posts/mxx4cb#De5Se

[4]如何优雅处理前端异常?: http://jartto.wang/2018/11/20/js-exception-handling/index.html

[5]Capture and report JavaScript errors with window.onerror | Product Blog • Sentry: https://blog.sentry.io/2016/01/04/client-javascript-reporting-window-onerror

[6]前端搞监控|Allan - 如何实现一套多端错误监控平台: https://www.yuque.com/zaotalk/posts/mxx4cb

[7]一步一步搭建前端监控系统:JS 错误监控篇: https://blog.fundebug.com/2019/07/06/how-to-monitor-javascript-error/

[8]撸一个前端监控系统: https://juejin.im/post/6844904054334685197

[9]结束裸奔 ppt: https://yun.tuia.cn/tuia/fed/openday/%E9%94%99%E8%AF%AF%E7%9B%91%E6%8E%A7%E5%A4%A9%E7%9C%BC%20-%20%E7%BB%93%E6%9D%9F%E8%A3%B8%E5%A5%94.pdf

上次更新: 11/8/2024, 10:19:43 AM