迈向前端 Leader - 落地前端监控

大家好,我是风骨,在前端领域能让我们深入探究的方向有很多,比如今天的主题:前端监控

在大型项目中,前端监控 是不可或缺的一部分。它的优势可以体现在以下场景:

  1. 稳定性:尽早发现程序运行错误并及时修复;
  2. 用户体验:性能监控分析,持续优化改善网站使用体验;
  3. 业务扩展:常见的数据埋点,如统计 PV 页面浏览量。

其中 稳定性用户体验 是我们完成前端基建必不可少的组成部分。

而要实现一套完整的前端监控,需要经历以下过程:

其中:

  • SDK,负责处理客户端(浏览器)程序在运行期间的监控日志收集和上报;
  • 日志服务器,负责接收 SDK 上报的 Log 日志,进行清洗过滤存入数据库;
  • 可视化平台,则是以可视化形式,直观展示上报过来的数据。

其中,日志服务器 使用 Node.js 技术栈实现 server 端逻辑,可视化平台 可以基于统计数据实现报表展示。

下面,我们重点从 0 到 1 一步步来构建一个 Web 监控 SDK。文章大纲如下:

毛遂自荐 :笔者最近在看工作机会,各位小伙伴 如果有适合的内推岗位,期待帮我引荐一下(可以在 掘金 上私信 或是 添加微信: iamcegz),感谢!个人简介如下:

男,27 岁,计算机专业,工作年限 6 年,Base 北京,擅长 React 技术栈 和 前端工程化建设。

一、设计 SDK

SDK 承担了前端监控和数据上报 的工作,通常我们会以 <script> js 脚本文件的形式接入到业务项目中。

下面,首要工作是建立一个 SDK 项目仓库,采用打包工具构建出 JS 脚本文件。

1、搭建 Rollup 构建环境

构建工具我们选用 Rollup,它非常适合构建一些工具库、组件库。下面我们初始化一个 monitor-sdk 目录,并完成 Rollup 打包配置。

bash 复制代码
mkdir monitor-sdk
cd monitor-sdk
npm init -y
npm install rollup rollup-plugin-terser @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-babel typescript @babel/preset-typescript @babel/preset-env @babel/core @babel/cli -D
tsc --init
touch index.ts
touch rollup.config.js
js 复制代码
// rollup.config.js
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import { babel } from "@rollup/plugin-babel";
import { terser } from "rollup-plugin-terser";
import path from "path";

const extensions = [".ts", ".tsx", ".js", ".jsx"];

export default () => ({
  input: path.resolve(__dirname, "index.ts"),
  output: {
    file: path.resolve(__dirname, "dist/myMonitor.js"),
    format: "umd",
    name: "myMonitor",
  },
  plugins: [
    resolve({
      extensions, // 指定 import 模块后缀解析规则
    }),
    commonjs(),
    babel({
      extensions,
      presets: [
        "@babel/preset-env",
        [
          "@babel/preset-typescript",
          {
            isTSX: true,
            allExtensions: true,
          },
        ],
      ],
      babelHelpers: "bundled",
    }),
    terser(),
  ],
});

最后,我们在 package.json 中加入 build 命令,运行 npm run build 完成 dist/myMonitor.js 构建。

json 复制代码
"scripts": {
  "build": "rollup -c rollup.config.js -w"
}

PS:另外我们可以配置 ESLint、Prettier 等前端规范工具,由于非本文中心主题,跳过详细描述。

2、异常监控

在前端,程序发生异常的种类有很多,比如:JS 代码执行错误、Promise 未被处理的错误、React/Vue 组件 render 错误、静态资源加载错误、请求 API 出错等。

以上这些异常场景,都需要我们进行监控并上报到服务器。

2.1、JS 代码执行错误

首先,我们模拟代码错误:访问一个未定义的对象的属性

html 复制代码
// examples/jsError.html
<body>
  <div id="container">
    <input type="button" value="点击抛出错误" onclick="errorClick()" />
  </div>

  <script>
    function errorClick() {
      // 模拟代码错误:访问一个未定义的对象的属性
      window.someVal.error = "error";
    }
  </script>
</body>

当点击按钮后,会发生报错:

在 JS 中未被 try/catch 捕获的代码错误,可以通过 window.addEventListener('error') 全局监听到。

在监控到错误以后,我们需要对错误进行 数据建模,拿到能够描述和定位此错误的有用信息。

异常日志数据建模可以遵循以下类型结构:ErrorLog

ts 复制代码
// interface/index.ts
export interface ErrorLog {
  // type 监控类型:error(代码错误)
  type: "error";
  // 错误类型:jsError(JS 代码错误)
  errorType: "jsError" | ...;
  // 错误信息
  message: string;
  // 错误发生的文件
  filename: string;
  // 错误发生的行列信息
  position: string;
  // 错误堆栈信息
  stack: string;
  // 错误发生在 DOM 到顶层元素的链路信息(使用选择器表示,如:body div#container input)
  selector?: string;
}

有了上面的分析,我们来实现 JS 代码执行错误监控。

新建 module/jsError.ts 文件,注册 window.addEventListener('error') 监听错误,并对错误信息进行数据建模得到上报 log 数据。

ts 复制代码
// index.ts
import injectJSError from "./modules/jsError";
injectJSError();

// modules/jsError.ts
import { ErrorLog } from "../interface";
import { formatStack } from "../utils";
import getLastEvent from "../utils/getLastEvent";
import getSelector from "../utils/getSelector";

export default function injectJSError() {
  // 1、监听全局未被 try/catch 捕获的错误
  window.addEventListener(
    "error",
    (event) => {
      console.log("js error event: ", event);
      const lastEvent = getLastEvent(); // 监听到错误后,获取到最后一个交互事件

      // 1.1、数据建模存储
      const errorLog: ErrorLog = {
        type: "error",
        errorType: "jsError",
        message: event.message,
        filename: event.filename,
        position: `${event.lineno}:${event.colno}`,
        stack: formatStack(event.error.stack),
        selector: lastEvent ? getSelector() : "",
      };
      console.log("js error log: ", errorLog);

      // 1.2、上报数据(TODO...)
    },
    // !!! 使用事件捕获进行监听
    true
  );
}

其中 errorType 标识是一个 JS 代码错误,messagefilenameposition 信息都可以从 event 错误事件对象上获取。

这里重点介绍一下 stackselector 的信息来源。

  1. stack 统计函数调用错误栈信息:

首先,访问 event.error.stack 得到的是发生错误的执行调用栈信息(String):

我们可以稍做加工一下,得到一个更直观的调用栈的信息,加工后展示如下:

formatStack 的实现:

ts 复制代码
// utils/index.ts
export function formatStack(stack: string) {
  return stack
    .split("\n")
    .slice(1)
    .map(item => item.replace(/^\s+at\s+/g, ""))
    .join("\n");
}

  1. selector 统计 DOM 节点层级树信息:

getLastEvent() 函数用来返回最近一个交互事件,当错误发生来自于用户与页面交互时,lastEvent 将有值是事件对象。它的实现通过在全局绑定相关交互事件:

ts 复制代码
let lastEvent: Event | null;
let lastEventPath: any[];

["click", "touchstart", "mousedown", "keydown"].forEach(eventType => {
  // 埋点方式:无痕埋点 -> 全部埋点
  document.addEventListener(
    eventType,
    event => {
      lastEvent = event;
      // 新版浏览器中 event.path 已被废弃,改用 event.composedPath()
      lastEventPath = event.path || event.composedPath();
    },
    {
      capture: true, // 以捕获形式监听(因为默认元素的事件都是冒泡形式,如果出现阻止默认事件,在这里将监听不到)
      passive: true,
    },
  );
});

// 获取最近一次的事件调用栈
export function getLastEventPath() {
  return lastEventPath;
}

// 获取最近一次的事件
export default function getLastEvent() {
  return lastEvent;
}

当错误发生时,事件对象 event.composedPath() 可以拿到 DOM 树层级信息:

最后,getSelector() 方法对则是对 DOM 树层级进行进行加工,得到一个包含 DOM 选择器的层级字符串。

ts 复制代码
// getSelector.ts 获取当前事件链路上的元素选择器
import { getLastEventPath } from "./getLastEvent";

function getSelectorByPath(path: any[]) {
  return path
    .reverse() // 翻转 Path 中的元素
    .filter(element => {
      // 过滤掉 window、document 和 html
      return element !== window && element !== document && element !== document.documentElement;
    })
    .map(element => {
      if (element.id) {
        return `${element.nodeName.toLowerCase()}#${element.id}`; // 返回 标签名#id
      } else if (element.className && typeof element.className === "string") {
        return `${element.nodeName.toLowerCase()}#${element.className}`; // 返回 标签名.class
      } else {
        return element.nodeName.toLowerCase(); // 返回 标签名
      }
    })
    .join(" ");
}

export default function getSelector() {
  const path = getLastEventPath();
  if (Array.isArray(path)) {
    return getSelectorByPath(path);
  }
}

现在,在上述示例中引入 monitor sdk js,点击按钮模拟 JS 执行错误,就可以监控到 errorLog 日志:

有了错误日志,接下来便是考虑数据上报。

2.2、数据上报方式

在前端,可供数据上报至服务器的方式有三种:

  1. ajax 请求;
  2. img GIF 图片 GET 请求方式上报,优点:速度快,没有跨域问题;
  3. navigator.sendBeacon() 方法通过 HTTP 将少量数据异步传输到 Web 服务器。

ajax 方式我们再熟悉不过了,不论是 xhr 对象还是 fetch 函数,都可以将数据传递到服务器。

navigator.sendBeacon() 也是一种不错的方式,它的最大优势在于 异步,确保数据在页面卸载过程中仍然能够被发送,而不会被中断。

PS: 有关 navigator.sendBeacon() 的使用可以查阅文档:developer.mozilla.org/zh-CN/docs/...

重点介绍一下 img GIF 的上报方式。

简单来说,我们可以在服务器上放置一个非常小的 gif 图片(1kb),将日志数据作为图片 url queryString 参数,访问这个图片来达成数据上报至服务器

下面来看看具体实现。

首先,创建一个 Tracker 类,并提供一个 send 方法,通过 img 标签访问服务器上的图片来完成日志上传。

ts 复制代码
// utils/tracker.ts
import { MonitorLog, MonitorTypeLog } from "../interface";
import getLogBaseData from "./getLogBaseData";

class Tracker {
  url: string;

  constructor() {
    // 上报日志服务器地址(服务器上的 gif 图片)
    this.url = "http://localhost:8080/send/monitor.gif";
  }

  send(data: MonitorTypeLog) {
    // 获取基础日志数据
    const baseData = getLogBaseData();
    const log: MonitorLog = {
      baseLog: baseData,
      ...data,
    };
    console.log("send log", log);

    // 进行数据上报
    const img = new window.Image();
    img.src = `${this.url}?data=${encodeURIComponent(JSON.stringify(log))}`;
  }
}

export default new Tracker();

这里 baseData 表示日志上报可以携带一些设备的基础信息,比如浏览器型号、版本等。此外,你还可以加入一些业务信息如 用户身份 进行上报。

设备信息来自于 navigator.userAgent,可以使用 ua-parser-js 三方库来完成设备参数解析。

ts 复制代码
// utils/getLogBaseData.ts
import { UAParser } from "ua-parser-js";
import { BaseLog } from "../interface";

// 获取设备信息
const { browser, device, os } = UAParser(navigator.userAgent);

/**
 * getLogBaseData 获取日志基本信息
 */
export default function getLogBaseData(): BaseLog {
  return {
    title: document.title,
    url: location.href,
    userAgent: navigator.userAgent,
    browser: `${browser.name} ${browser.version}`,
    device: `${device.model} ${device.vendor}`,
    os: `${os.name} ${os.version}`,
  };
}

最后,在监控到报错时,调用 tracker.send() 将错误日志上报到服务器。

diff 复制代码
// jsError.ts
export default function injectJSError() {
  // 1、监听全局未被 try/catch 捕获的错误
  window.addEventListener(
    "error",
    event => {
      const lastEvent = getLastEvent(); // 监听到错误后,获取到最后一个交互事件

      // 1.1、数据建模存储
      const errorLog: ErrorLog = {...}

+     // 1.2、上报数据
+     tracker.send(errorLog);
    },
    // !!! 使用捕获
    true,
  );
}

接下来,就是在服务端的处理,通过解析图片请求,拿到请求上的 queryString 参数。相关部分可以查看下文 「日志服务器 - 接收上报数据」

2.3、Promise 未处理的错误

Promise 错误一般是指:在 Promise execute、then 方法中代码执行出错,或 Promise execute reject() 变成失败态,且没有被 .catch 函数进行捕获处理。

如下,是 Promise 抛出错误的代码示例:

html 复制代码
// examples/promiseError.html
<body>
  <div id="container">
    <input type="button" value="点击抛出 Promise 错误" onclick="promiseErrorClick()" />
  </div>

  <script>
    function promiseErrorClick() {
      new Promise(function (resolve, reject) {
        // 抛出错误方式 1:
        window.someVal.error = "error";
        // 抛出错误方式 2:
        reject("错误原因:模拟一个 Promise 错误。");
      });
    }
  </script>
</body>

然而,Promise 未处理的错误不会被 window.addEventListener('error') 所监听,需要使用另一个监听事件 unhandledrejection 来完成。

同时,unhandledrejection 的事件对象 event.reason 在这两个错误场景下表现有所不同:

  1. 代码执行错误event.reason 是一个对象,reason.stack 包含调用栈信息,可以从中拿到错误文件、行和列等信息;
  2. reject() 变更 Promise 为失败状态event.reason 是一个字符串,值为传递给 reason() 函数的失败原因参数。

因此,在数据建模时,需要依据 event.reason 进行区分:

ts 复制代码
// modules/jsError.ts
...

export default function injectJSError() {
  // 1、监听全局未被 try/catch 捕获的错误
  window.addEventListener("error", ...);
  
  // 2、监听未被捕获的 Promise 错误
  window.addEventListener(
    "unhandledrejection",
    event => {
      console.log("Promise error event: ", event);
      const lastEvent = getLastEvent(); // 监听到错误后,获取到最后一个交互事件

      let message;
      const reason = event.reason; // Promise 失败的原因
      let filename;
      let line = 0;
      let column = 0;
      let stack = "";
      if (typeof event.reason === "string") {
        // 情况 1、是 Promise reject 抛出的错误(没有办法获取 stack 等信息)
        message = reason;
      } else if (typeof reason === "object") {
        message = reason.message;
        // 情况 2、是 Promise 中 JS 代码执行出错
        if (reason.stack) {
          // 从错误信息中匹配到关键信息。stack 示例:at http://localhost:8080/examples/promiseError.html:29:32
          const matchResult = reason.stack.match(/at\s+(.+):(\d+):(\d+)/);
          filename = matchResult[1];
          line = matchResult[2];
          column = matchResult[3];
          stack = formatStack(reason.stack);
        }
      }

      // 2.1、数据建模存储
      const errorLog: ErrorLog = {
        type: "error",
        errorType: "promiseError", // 错误类型 - Promise 代码错误
        message,
        filename,
        position: `${line}:${column}`,
        stack,
        selector: lastEvent ? getSelector() : "",
      };

      // 2.2、上报数据
      tracker.send(errorLog);
    },
    true,
  );
}

对于 Promise 中的代码执行错误,收集到的错误日志信息和「JS 代码执行错误」基本一致:

而对于 Promise reject 变更为失败态,收集到的错误日志信息如下:

2.4、资源加载错误

资源一般是指 JS、CSS、图片,当访问这些资源出错或 404 找不到时,我们可以监控进行上报。

如下,我们随意访问一个不存在的资源,模拟资源加载 404。

html 复制代码
// examples/loadResourceError.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>资源加载出错.</title>
    <script src="../dist/myMonitor.js"></script>
  </head>
  <body>
    <!-- <img src="/someError.png" /> -->
    <!-- <link rel="stylesheet" href="/someError.css" /> -->
    <script src="/someError.js"></script>
  </body>
</html>

加载资源,需要使用指定的 HTML 标签元素(script、link、img),比如加载 JS 文件需要使用 script 标签。

除了通过 标签 onerror 事件监听错误外,还可以通过 window.addEventListener('error') 在全局统一监听资源加载错误。

我们在 jsError.ts 中的 error 事件监听处理函数中,加入监控资源加载出错逻辑。

js 复制代码
// modules/jsError.ts
export default function injectJSError() {
  window.addEventListener(
    "error",
    event => {
      // 监听 JS/CSS 资源文件加载错误
      const target = event.target as HTMLScriptElement | HTMLImageElement | HTMLLinkElement | null;
      let filename;
      if (target && (filename = (target as HTMLScriptElement | HTMLImageElement).src || (target as HTMLLinkElement).href)) {
        // 1、数据建模存储
        const log: ErrorLog = {
          type: "error",
          errorType: "loadResourceError", // 错误类型 - JS/CSS 资源加载错误
          message: `${filename} resource loading fail.`,
          filename, // 报错的文件
          tagName: target.tagName, // 资源标签名称
          selector: getSelector(event.target as HTMLElement), // body script
        };
        // 2、上报数据
        tracker.send(log);
      }
      
      // 监听 JS 代码执行出错
      else { ... }
    },
    true,
  );
}

对于 script 和 img 元素,可通过 src 属性来识别,而 link 则可以用 href 属性来识别。

最后在数据建模获取 selector 时,传递 target 元素来获取节点树上的层级信息。

diff 复制代码
export default function getSelector(ele?: HTMLElement) {
  const path = getLastEventPath();
  if (Array.isArray(path)) {
    return getSelectorByPath(path);
+ } else if (ele) {
+   return getSelectorByEle(ele);
+ }
}

+ function getSelectorByEle(ele: HTMLElement) {
+   let node: HTMLElement | null = ele;
+   const path: string[] = [];
+   while (node && filterTopLevelNode(node)) {
+     path.unshift(getEleSelector(node));
+     node = node.parentElement;
+   }
+   return path.join(" ");
+ }

+ function filterTopLevelNode(element: Window | Document | HTMLElement) {
+   // 过滤掉 window、document 和 html
+   return element !== window && element !== document && element !== + document.documentElement;
+ }

+ function getEleSelector(element: HTMLElement) {
+   if (element.id) {
+     return `${element.nodeName.toLowerCase()}#${element.id}`; // 返回 标签名#id
+   } else if (element.className && typeof element.className === "string") {
+     return `${element.nodeName.toLowerCase()}#${element.className}`; // 返回 标签名.class
+   } else {
+     return element.nodeName.toLowerCase(); // 返回 标签名
+   }
+ }

2.5、API 请求报错

在客户端(浏览器),可以通过 xhr/fetch 请求服务端接口。为了保证前后端交互的稳定性,双方都可以去做 API 请求监控

xhr ajax 为例,要监控 API 请求的状态,推荐的做法是采用 「重写 XMLHttpRequest 构造函数原型上的方法」 来实现。

以下是一个简单的模拟 xhr 请求服务器 API 出现 500 错误的示例,对应前后端代码如下:

js 复制代码
// 前端:
window.onload = () => {
  // 访问一个报错的 api,模拟 500 错误
  const xhr = new XMLHttpRequest();
  xhr.open("get", "http://localhost:3000/error?name=test", true);
  xhr.setRequestHeader("Content-Type", "application/json");
  xhr.onload = function () {
    // 在 onload 事件中检查 xhr.status 是否在 200-299 的范围内。如 404 或 500 错误,都会在这里进行监控上报。
    console.log("onload: ", xhr);
  };
  xhr.onerror = function (error) {
    // onerror 事件主要用于处理 跨域问题、网络错误 等无法正常连接到服务器的情况,例如本地无网络。
    console.log("error: ", error, xhr);
  };
  xhr.send();
};

// 服务端:
const http = require("http");
const server = http.createServer((req, res) => {
  // 允许跨域
  res.setHeader("Access-Control-Allow-Origin", "*");
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");

  if (req.method === "OPTIONS") {
    // 处理预检请求
    res.writeHead(204);
    res.end();
  } else {
    
    if (req.url.startsWith("/error")) {
      res.writeHead(500, { "Content-Type": "text/plain" });
      res.end("Server Error, code is 500.");
    } else {
      // 处理正常请求
      res.writeHead(200, { "Content-Type": "text/plain" });
      res.end("Hello, world!");
    }
  }
});
server.listen(3000, () => console.log("Server running on port 3000"));

重写 XMLHttpRequest 原型方法来完成 API 请求监控,分以下步骤:

  1. 重写 XMLHttpRequest.prototype.setRequestHeader 方法,保存设置的请求头信息;
  2. 重写 XMLHttpRequest.prototype.open 方法,保存 API 请求的 methodurl
  3. 重点!重写 XMLHttpRequest.prototype.send 方法,当判定请求出错时(如:跨域、404、500),收集请求信息进行数据上报。

另外,我们还需要配置一个白名单 whiteList,跳过无需监控的请求,如「监控日志上报服务器请求」。

核心功能实现如下:

js 复制代码
// modules/xhr.ts
import { ErrorLog } from "../interface";
import { parseQueryString } from "../utils";
import tracker from "../utils/tracker";

// 不需要监控的接口白名单
const whiteList = [
  "http://localhost:8080/send/monitor.gif", // 日志服务接口
];

// 增强 XHR:通过重写 XHR 主要方法,实现拦截和增强 XHR
export default function injectXHR() {
  const XMLHttpRequest = window.XMLHttpRequest;

  // 1、重写 setRequestHeader 方法增强功能 - 记录 request headers 数据
  const oldSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
  XMLHttpRequest.prototype.setRequestHeader = function (key, value) {
    if (!this.requestHeaders) (this.requestHeaders = {});
    this.requestHeaders[key] = value;
    return oldSetRequestHeader.apply(this, arguments);
  };

  // 2、重写 open 方法增强功能 - 记录请求方式和 url
  const oldOpen = XMLHttpRequest.prototype.open; // 记录老的 open 方法
  XMLHttpRequest.prototype.open = function (method, url, async) {
    // 跳过 白名单接口 防止出现死循环。
    if (whiteList.indexOf(url) === -1) {
      this.logData = { method: method.toUpperCase(), url }; // 存储数据
    }
    return oldOpen.apply(this, arguments);
  };

  // 3、重写 send 方法增强功能 - 监控上报数据
  const oldSend = XMLHttpRequest.prototype.send; // 记录老的 send 方法
  XMLHttpRequest.prototype.send = function (body) {
    if (this.logData) {
      // 在 send 发送之前,记录请求开始时间
      const startTime = Date.now();
      const handler = (type: "load" | "error") => {
        return () => {
          const duration = Date.now() - startTime; // 持续的时间
          const status = this.status; // 200 | 400 | 500
          const statusText = this.statusText; // OK | Server Error
          const { url, method } = this.logData;
          const params = ['GET', 'DELETE'].indexOf(method) > -1 ? parseQueryString(url) : body;

          // 当请求发生错误时,上报数据(忽略无网络的错误,处理像 跨域错误、404、500 等错误)
          if ((type === "error" && window.navigator.onLine) || (type === "load" && status >= 400)) {
            const log: ErrorLog = {
              type: "error",
              errorType: "xhrError", // 错误类型是 xhr
              message: statusText, // 错误信息
              xhrData: {
                eventType: type, // load | error
                url, // api 路径
                method, // 请求方式
                header: this.requestHeaders, // 请求头
                params, // 请求参数
                duration, // 请求时长
                status,
                response: this.response ? JSON.stringify(this.response) : "", // 请求结果
              },
            };
            console.log("XHR log: ", log);
            tracker.send(log); 
          }
        };
      };
      // 服务端返回 status 为 500 也会进入 load,需要进一步判断 status
      this.addEventListener("load", handler("load"), false);
      this.addEventListener("error", handler("error"), false);
    }
    return oldSend.apply(this, arguments);
  };
}

3、白屏监控

白屏通常是由 代码执行错误 引起,导致框架(如 React)渲染流程中断。所以白屏可以结合【异常监控】一起上报,可以关联到导致白屏的错误原因。

我们思考一下:如何判定当前页面是白屏呢?

可以采用【页面采样识别检测】:通过在页面上确定多个采样点,使用 elementFromPoint 方法获取采样点的元素,判断采样点元素是否为有效元素(比如非 body、#root 等根节点)。

关于采样点的定义,以页面为中心的 水平 和 垂直线上,来定义多个采样点。

白屏检测的实现如下:

ts 复制代码
// modules/xhr.ts
export default function checkWhiteScreen() {
  // 最顶层的空白元素(判断是白屏的依据)
  const wrapperElements = ["html", "body", "#root"];
  let emptyPoints = 0; // 记录空白的点的个数

  function getSelector(element: Element) {
    let selector;
    if (element.id) {
      selector = `#${element.id}`;
    } else if (element.className && typeof element.className === "string") {
      // prettier-ignore
      selector = "." + element.className.split(" ").filter(item => !!item).join(".");
    } else {
      selector = element.nodeName.toLowerCase();
    }
    return selector;
  }

  function isWrapper(element: Element) {
    const selector = getSelector(element);
    if (wrapperElements.indexOf(selector) > -1) {
      emptyPoints++; // 是空白点
    }
  }

  for (let i = 1; i <= 9; i++) {
    // 在高度一半的位置,横坐标均分取 9 个点,查看这 9 个点上的元素
    const xElements = document.elementFromPoint(
      (window.innerWidth / 10) * i,
      window.innerHeight / 2,
    );
    // 在宽度一半的位置,纵坐标均分取 9 个点,查看这 9 个点上的元素
    const yElements = document.elementFromPoint(
      window.innerWidth / 2,
      (window.innerHeight / 10) * i,
    );

    // 判断点的位置,是否是空白元素
    isWrapper(xElements!);
    isWrapper(yElements!);
  }

  // 定义阈值,比如 当所有的点(18个)都是空白点,那么就认为是空白页面,有一个点上有元素,就认为不是空白页面。
  if (emptyPoints === 18) {
    return true;
  }
  return false;
}

当发生 JS 执行错误后,进行白屏检测并一起上报数据。

diff 复制代码
// modules/jsError.ts
...
+ import checkWhiteScreen from "../utils/checkWhiteScreen";

export default function injectJSError() {
  window.addEventListener(
    "error",
    event => {
      ...

+     const isWhiteScreen = checkWhiteScreen(); // 检查是否白屏
      // 1.1、数据建模存储
      const errorLog: ErrorLog = {
        // kind: "stability", // 监控指标的大类
        type: "error",
        errorType: "jsError",
        message: event.message,
        filename: event.filename,
        position: `${event.lineno}:${event.colno}`,
        stack: formatStack(event.error.stack),
        selector: lastEvent ? getSelector() : "",
+       isWhiteScreen,
      };
      console.log("js error log: ", errorLog);

      // 1.2、上报数据
      tracker.send(errorLog);
    },
    // !!! 使用捕获
    true,
  );
  ...
}

4、统计页面加载时间

页面加载时间的指标信息有很多,一般会重点分析 DOM 树构建完成的时间(DOMContentLoaded) 和 页面完整的加载时间(load

浏览器 PerformanceNavigationTiming 对象提供了关于页面加载性能的详细信息。(对应旧版本的 performance.timing 对象

在统计加载时间前,我们先了解两个时间的执行时机:

  • DOMContentLoaded,是一个 DOM 事件,当浏览器完成 HTML 文档的解析,构建完成 DOM 树后触发,但不包含图片、CSS、JavaScript 等外部资源的加载。
  • onLoad,是一个 JS 事件,它在页面的所有资源(包括 HTML、CSS、图片、JavaScript 等)完全加载完成后触发。

如何统计这两个加载时间呢?需要用到以下加载性能信息:

  • fetchStart:浏览器开始发起 HTTP 请求文档的时间;
  • domContentLoadedEventStart:DOM 树构建完成后触发 DOMContentLoaded 事件的时间;
  • loadEventStart:页面所有资源(包括图片)加载完成后触发 window.onload 事件发生的时间。

如下示例,在 DOM 树构建完成后,请求一个图片资源:

html 复制代码
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  </head>
  <body>
    <img src="https://picsum.photos/200/300" alt="" />
  </body>
</html>

对应的先得到 DOMContentLoaded 时间,等待图片加载完成后,得到 onLoad 时间。

监听统计加载时间实现如下:

js 复制代码
// modules/timing.ts
import { TimingLog } from "../interface";
import tracker from "../utils/tracker";

export default function injectTiming() {
  window.addEventListener("load", () => {
    let DOMContentLoadedTime = 0,
      loadTime = 0;

    // 新版浏览器 API:PerformanceNavigationTiming 提供了关于页面加载性能的详细信息,替代旧的 performance.timing
    if (performance.getEntriesByType) {
      const perfEntries = performance.getEntriesByType("navigation");
      if (perfEntries.length > 0) {
        const navigationEntry = perfEntries[0];
        const { domContentLoadedEventStart, loadEventStart, fetchStart } =
          navigationEntry as PerformanceNavigationTiming;

        // DOM 树构建完成后触发 DOMContentLoaded 事件
        DOMContentLoadedTime = domContentLoadedEventStart - fetchStart;

        // 页面完整的加载时间
        loadTime = loadEventStart - fetchStart;
      }
    }
    // 旧版浏览器降级使用 performance.timing
    else {
      const { fetchStart, domContentLoadedEventStart, loadEventStart } = performance.timing;
      DOMContentLoadedTime = domContentLoadedEventStart - fetchStart;
      loadTime = loadEventStart - fetchStart;
    }

    // 1、数据建模存储
    const log: TimingLog = {
      type: "timing",
      DOMContentLoadedTime,
      loadTime,
    };

    // 2、上报数据
    tracker.send(log);
  });
}

加载时间统计结果大致如下:

PS:页面加载性能详细信息参考资料:PerformanceNavigationTiming

5、性能指标

性能指标,类似算法里面的【时间复杂度】,用来衡量一个网站的响应速度。通过监控和分析性能指标,来改变页面性能,提升用户体验。

常见的性能指标有:

  1. FP, First Paint(首次绘制 - 首次像素绘制):包括了任何用户自定义的背景绘制,它是首先将像素绘制到屏幕的时刻;
  2. FCP, First Content Paint(首次内容绘制)是浏览器将第一个有内容的 DOM 渲染到屏幕的时间,可能是文本、图像、SVG等,这其实就是白屏时间
  3. FMP, First Meaningful Paint(首次有意义内容绘制):页面有意义的内容(由我们指定)渲染的时间;
  4. LCP, (Largest Contentful Paint)(最大内容渲染):LCP 指标代表的是视窗最大可见图片或者文本块的渲染时间。(以百度首页为例,LCP 对应的元素是百度 Logo);

5.1、FP 和 FCP

如下示例,我们在 0.5 后给 div 设置背景色进行首次像素绘制(FP),1s 后在页面呈现有效内容(FCP)。

html 复制代码
<body>
  <div id="root"></div>
  <script>
    // 0.5 后进行首次像素绘制
    setTimeout(() => {
      root.style.backgroundColor = "gray";
      root.style.height = "100px";
    }, 500);

    // 1s 后在页面呈现有效内容
    setTimeout(() => {
      root.innerHTML = "content";
    }, 1000);
  </script>
</body>

FP 和 FCP 指标都可以通过 PerformanceObserver API 观察 type: paint 来计算。

ts 复制代码
// modules/paint.ts
export default function injectPaint() {
  if (PerformanceObserver) {
    let FP, FCP;

    // 1、监控性能指标 FP(First Paint) 和 FCP(First Contentful Paint)
    const observerFPAndFCP = new PerformanceObserver(function (entryList) {
      const perfEntries = entryList.getEntries();
      for (const perfEntry of perfEntries) {
        if (perfEntry.name === "first-paint") {
          FP = perfEntry;
          console.log("首次像素绘制 时间:", FP?.startTime);
        } else if (perfEntry.name === "first-contentful-paint") {
          FCP = perfEntry;
          console.log("首次内容绘制 时间:", FCP?.startTime);
          observerFPAndFCP.disconnect(); // 得到 FCP 后,断开观察,不再观察了
        }
      }
    });
    // 观察 paint 相关性能指标
    observerFPAndFCP.observe({ entryTypes: ["paint"] });
  }
}

5.2、FMP

这里我们先认识一个 HTML 属性:

elementtiming 属性用于标记页面中特定元素(如图片、视频、文本块等),以便通过 PerformanceObserverelement 类型监控这些元素的加载和渲染时间。

PerformanceObserverelement 类型主要支持监控以下元素类型:

  • 图片(<img> :结合设置 elementtiming 属性来监控其渲染时间;
  • 文本块(<p><div>(验证发现 <span> 不行)) :这类元素内容是纯文本,结合设置 elementtiming 属性来监控其渲染时间;

如下示例,我们在 1.5s 后向页面添加 有意义(属性标识) 的元素。

html 复制代码
<script>
// 1.5s 后向页面添加 有意义(属性标识) 的元素
setTimeout(() => {
  const ele = document.createElement("div");
  ele.innerHTML = "meaningful ele.";
  ele.setAttribute("elementtiming", "meaningful ele"); // 设置 root 元素为「最有意义的元素」
  document.body.appendChild(ele);
}, 1500);
</script>

我们使用 PerformanceObserver API 观察 type: element 来计算 FMP。

ts 复制代码
// modules/paint.ts
export default function injectPaint() {
  if (PerformanceObserver) {
    let FP, FCP, FMP;

    ...

    // 2、监控性能指标:FMP(First Meaningful Paint)
    const observerFMP = new PerformanceObserver(entryList => {
      const perfEntries = entryList.getEntries();
      FMP = perfEntries[0];
      console.log("首次有意义元素绘制 时间:", FMP?.startTime);
      observerFMP.disconnect(); // 断开观察,不再观察了
    });
    observerFMP.observe({ entryTypes: ["element"] });
  }
}

5.3、LCP

LCP 可通过 PerformanceObserver API 观察 type: largest-contentful-paint 来监听统计。

需要注意的是,LCP 可能会在页面加载过程中多次触发,尤其是在最大内容元素发生变化时。

如下示例,由于 div2 的内容大于之前的 div1 内容,LCP 的统计会触发两次:

js 复制代码
const div1 = document.createElement("div");
div1.innerHTML = "这是一段很长的文本";
document.body.appendChild(div1);

setTimeout(() => {
  const div2 = document.createElement("div");
  div2.innerHTML = "这是一段很长很长很长的文本";
  document.body.appendChild(div2);
}, 500);

那么什么时候停止 LCP 监控呢?我们可以在需要上报性能指标时(比如 load 事件),停止对 LCP 的监控,这时收集到的 LCP 就是此刻页面中最大的内容。

ts 复制代码
// modules/paint.ts
export default function injectPaint() {
  if (PerformanceObserver) {
    let FP, FCP, FMP, LCP;

    ...

    // 3、创建性能观察者,观察 LCP
    const observerLCP = new PerformanceObserver(entryList => {
      const perfEntries = entryList.getEntries();
      LCP = perfEntries[0];
      console.log("最大内容绘制 时间:", LCP?.startTime, perfEntries);
    });
    // 观察页面中最大内容的绘制
    observerLCP.observe({ entryTypes: ["largest-contentful-paint"] });

    // TODO... 在上报性能指标数据的时候,停止观察。
    observerLCP.disconnect();
  }
}

5.4、上报性能指标

收集到 FP、FCP、FMP、LCP 性能指标以后,便可以上报数据。上报时机比如可以是页面 load 以后等待 3s 进行上报。

js 复制代码
// modules/paint.ts
export default function injectPaint() {
  if (PerformanceObserver) {
    let FP: PerformanceEntry | null = null;
    let FCP: PerformanceEntry | null = null;
    let FMP: PerformanceEntry | null = null;
    let LCP: PerformanceEntry | null = null;

    ...
    
    // 上送性能指标
    window.addEventListener("load", () => {
      setTimeout(() => {
        // 在上报性能指标数据的时候,停止 LCP 的观察。
        observerLCP.disconnect();
        const log: PaintLog = {
          type: "paint",
          FP: FP?.startTime, // FP
          FCP: FCP?.startTime, // FCP
          FMP: FMP?.startTime, // FMP
          LCP: LCP?.startTime, // LCP
        };
        console.log("paint log: ", log);
        tracker.send(log);
      }, 3000);
    });
  }
}

6、卡顿监控

在 JS 同步执行复杂的计算、大量的 DOM 操作等工作的时候,主线程会被占用,其他操作(比如用户交互、动画渲染)都会被阻塞。

PerformanceObserver 是一个强大的 API,可以用来监听各种性能事件,包括长任务(longtask)。当主线程上的任务执行时间超过 100 毫秒时(设定一个阈值),可以认为这是一个长任务,可能会导致页面卡顿。

下面我们使用 while 循环模拟一个 200ms 长任务:

html 复制代码
// examples/longTask.html
<body>
  <button id="longTaskBtn">长任务</button>
  <script>
    function longTask() {
      const start = Date.now();
      while (Date.now() < 200 + start) {}
    }
    longTaskBtn.addEventListener("click", longTask);
  </script>
</body>

新建 module/longTask.ts 文件,监听 长任务(longtask) 作为卡顿现象进行数据上报。

ts 复制代码
// module/longTask.ts
import { PerformanceLog } from "../interface";
import getLastEvent from "../utils/getLastEvent";
import getSelector from "../utils/getSelector";
import tracker from "../utils/tracker";

export default function injectLongTask() {
  if (PerformanceObserver) {
    const observerLongTask = new PerformanceObserver(list => {
      list.getEntries().forEach(entry => {
        // 执行时长大于 100 ms
        if (entry.duration > 100) {
          const lastEvent = getLastEvent();
          
          const log: LongTaskLog = {
            type: "longTask",
            startTime: entry.startTime, // 开始时间
            duration: entry.duration, // 持续时间
            selector: lastEvent ? getSelector() : "",
            eventType: lastEvent?.type,
          };

          tracker.send(log);
        }
      });
    });

    observerLongTask.observe({ entryTypes: ["longtask"] });
  }
}

二、重难点实现

1、Source Map 源代码定位

在前端项目中,为了节省构建资源体积,不暴露业务逻辑,都会选择将代码进行混淆和压缩。在优化性能,在提升用户体验的同时,也为异常的处理带来了麻烦。

Source Map 是一个源代码信息文件,里面存储着代码压缩混淆前后的对应关系。我们输入混淆后的行列号,就能够获得对应的原始代码的行列号,结合源代码文件便可定位到真实的报错位置

但在生产环境下,我们不建议将 Source Map 放到网站上,对于具有一定规模和保密性的项目,这样等于将页面逻辑直接暴露给了网站使用者。

在这种情况下,监控 SDK 收集和上传的错误信息也是混淆和压缩后的,并不利于我们定位异常。那有没有其他办法来定位错误呢?

一般来说 Source Map 的应用都是在监控系统中,开发者构建完应用后,通过插件(例如自定义 webpack plugin)将 Source Map 文件(含源代码)上传至监控平台中 。(例如 Sentry 监控平台也是这样做的)

一旦客户端上报错误后,我们就可以通过 source-map 这个库来还原错误信息在源代码中的位置,方便开发者快速定位线上问题。

2、错误信息聚合

为了避免异常错误列表被大量的重复上报给占满,需要将具有相同特征的错误上报,归类为同一种异常,并且在统计平台只对用户暴露这种聚合后的异常。

一个错误信息的结构如下:

  • name: 异常的 Type,例如 TypeError, SyntaxError, DOMError
  • Message:异常的相关信息,通常是异常原因,例如 a is not defined.
  • Stack 异常的上下文堆栈信息,通常为字符串,例如 errirClick、HTMLInputElement.onclick

经过监控 SDK 收集到的 error log 如下:

错误信息聚合的思路 - 为每个错误生成唯一的标识符(uid)

我们可以使用 name + message + stack 作为聚合依据,生成一个 hash 值 作为这个错误的唯一性的 ID

如果后续捕获到的错误生成的 hash 值 与之前某个错误的 hash 值 相同,则认为是重复错误,不再进行数据入口或者是统计错误发生次数 + 1。

3、数据加工和清洗

数据加工和清洗发生在服务端。在对数据进行入库前,进行数据加工和清洗的意义在于:

  • 数据加工 可以将一些异构数据转换为统一的格式,提取出关键指标和有价值的信息,以及扩展一些额外信息(如:在服务端才能获取的 IP地址
  • 数据清洗 可以剔除无效、重复或冗余的数据,减少存储空间的浪费,同时提高数据处理的效率。

4、异常报警

在出现程序执行出错异常后,需要及时反馈通知到开发人员,及时跟进和处理问题。

错误报警可以从两个维度进行:对新增异常的报警 和 对错误指标的数量(达到某个阈值)的报警

常见的报警的方式:邮件报警、接入办公即时通信应用(自研产品、企业钉钉)。

文末

以上内容是作者结合当前所掌握的知识,整理出来的一套前端监控实现思路,后续也会继续根据应用场景,对本文内容进行扩充。感谢读者的阅读!

参考:
字节前端监控实践

相关推荐
陈大鱼头14 分钟前
AI驱动的前端革命:10项颠覆性技术如何在LibreChat中融为一体
前端·ai 编程
Gazer_S39 分钟前
【解析 ECharts 图表样式继承与自定义】
前端·信息可视化·echarts
剪刀石头布啊41 分钟前
视觉格式化模型
前端·css
一 乐1 小时前
招聘信息|基于SprinBoot+vue的招聘信息管理系统(源码+数据库+文档)
前端·javascript·数据库·vue.js·招聘系统
念九_ysl1 小时前
Vue3 + ECharts 数据可视化实战指南
前端·信息可视化·echarts
Gazer_S1 小时前
【Auto-Scroll-List 组件设计与实现分析】
前端·javascript·数据结构·vue.js
前端加油站1 小时前
前端开发人员必备的Mac应用
前端
可观测性用观测云1 小时前
每一份投入,都该物有所值:观测云如何用按需计费重塑可观测性价值
监控
庸俗今天不摸鱼1 小时前
【万字总结】前端全方位性能优化指南(四)——虚拟DOM批处理、文档碎片池、重排规避
前端·性能优化·dom
harry7591 小时前
React 18+ 安全访问浏览器对象终极指南:从原理到生产级解决方案
前端·javascript