玩命加载中 . . .

【前端监控系统开发实录】之原生 SDK 开发(二)


在之前的文章我们已经理清 SDK 的开发流程:

  1. 明确采集数据
  2. 封装采集方法
  3. 数据上报后端
  4. 接入项目测试

那么本文就来看看怎么封装采集方法。

异常监控

要想监控异常,首先我们要有异常可以监控,因此这里搭建了一个 vue 项目,在页面中做了一些按钮,每个按钮代表一种异常,我们要达到的效果:每点击一个按钮,SDK 要捕获到对应的错误,在控制台打印出捕获到的信息

sdk 中 example 文件夹下 vue 项目的一个页面:

<template>
  <div class="left">
    <h1>异常数据</h1>

    <h2>前端异常</h2>
    <button class="hello" @click="bugJs">JS 代码执行异常</button>
    <button class="world" @click="bugPromise">Promise 异常</button>
    <button class="hi" @click="bugAsset">静态资源加载异常</button>
    <button class="foursheep" @click="bugConsole">console.error 异常</button>
    <button class="good" @click="bugCors">跨域异常</button>
    <img src="http://localhost:8888/nottrue.jpg" />

    <br />

    <h2>接口异常</h2>
    <button @click="bugNoRespond">未响应/超时响应异常</button>
    <button @click="bugInterface4">4xx 请求异常</button>
    <button @click="bugInterface5">5xx 服务器异常</button>
    <button @click="bugPowerless">权限不足</button>

    <h1>白屏异常</h1>
    <button @click="bugWhiteScreen">白屏异常</button>
  </div>

  <div class="right">
    <h1>行为数据</h1>
    <button>用户设备类型,浏览器版本,webview引擎类型</button>
    <button>获取页面性能指标</button>
    <button>点击事件</button>
    <button>
      <RouterLink to="/">路由跳转</RouterLink>
    </button>
    <button @click="getPv">PV、UV</button>
  </div>
</template>

<script setup lang="ts">
    import { axiosIntance } from "@/utils/axios";
    import axios from "axios";
    import { login } from "@/api/modules/user";
    const bugJs = () => {
      window.someVar.error = "error";
    };
    const bugPromise = () => {
      new Promise(function (_, reject) {
        window.someVar.error = "error";
      });
    };
    const bugAsset = function () {
      console.log("bugAsset");
    };
    const bugConsole = function () {
      console.error(new Error("错误捕获555"));
    };
    const bugCors = function () {
      login({
        url: "/test",
        method: "post",
        data: '你好foursheep',
      })
    };
    const bugNoRespond = function () {
      // timeout
      axiosIntance
          .get("/api", {
            timeout: 10,
          })
          .then((res) => {
            console.log("请求成功", res);
          })
          .catch((e) => {
            console.log("请求失败", e);
          });
    };
    const bugInterface4 = function () {
      // 404
      axios
          .get("/api")
          .then((res) => {
            console.log("请求成功", res);
          })
          .catch((e) => {
            console.log("请求失败", e);
          });
    };
    const getPv = () => {
      console.log("getPv");
    };
    const bugInterface5 = function () {
      fetch("/asdasdasdasd", {
        method: "post",
        body: "kongming",
      });
    };
    const bugPowerless = function () {
      console.log("powerless");
    };
    const bugWhiteScreen = function () {
      console.log("页面 load 时已监控");
    };
</script>

<style></style>

页面呈现效果是这样的:
简单的界面

到这里,我们已经简单制造好了各种异常,下面看看怎么捕获。

封装的 ts 类型

// 错误类型
export enum mechanismType {
    JS = 'jsError',
    RS = 'resourceError',
    UJ = 'unhandledrejectionError',
    HP = 'httpError',
    CS = 'corsError',
    VUE = 'vueError',
}

封装的公共函数

import { mechanismType } from "../type";
import { AxiosError } from "axios";

// 判断是 JS异常、静态资源异常、还是跨域异常
export function getErrorKey (event: ErrorEvent | Event) {
    const target = event.target;
    const isElementTarget: boolean = target instanceof HTMLScriptElement || target instanceof HTMLLinkElement || target instanceof HTMLImageElement;
    if (isElementTarget) {
        return mechanismType.RS;
    }
    if (event instanceof AxiosError) {
        return mechanismType.CS;
    }
    return mechanismType.JS;
};

// 获取用户最后一个交互事件
export function getLastEvent() {
    let lastEvent: Event;
    [
        'click',
        'mousedown',
        'mouseover',
        'keydown',
        'touchstart',
    ].forEach(eventType => {
        window.addEventListener(eventType, (event) => {
            lastEvent = event;
        }, {
            capture: true,
            passive: true // 默认不阻止默认事件
        });
    })
    return lastEvent;
}

// 获取选择器
export function getSelector(pathsOrTarget: any) {
    const handleSelector = function (pathArr: any) {
        return pathArr.reverse().filter((element: any) => {
            // 去除 document 和 window
            return element !== document && element !== window;
        }).map((element: any) => {
            const {id, className, tagName} = element;
            if (id) {
                return `${tagName.toLowerCase()}#${id}`;
            } else if (className && typeof className === 'string') {
                return `${tagName.toLowerCase()}.${className}`;
            } else {
                return tagName.toLowerCase();
            }
        }).join(' ');
    }

    if (Array.isArray(pathsOrTarget)) {
        return handleSelector(pathsOrTarget);
    } else {
        let pathArr = [];
        while (pathsOrTarget) {
            pathArr.push(pathsOrTarget);
            pathsOrTarget = pathsOrTarget.parentNode;
        }
        return handleSelector(pathArr);
    }
}

JS 代码执行异常

捕获 JS 运行异常有两种方法:

  1. window.onerror。这是一个全局变量,默认值为 null当有 js 运行触发错误时,window 会触发 error 事件,并执行 window.onerror() ,借助这个特性,我们可以捕获全局的 JS运行异常,并且通过对 window.onerror 进行重写以获取异常信息;

    window.onerror = (msg, url, row, col, error) => {
        // 1. 获取报错信息
        // 2. 上报报错信息
        return true; // 返回 true,阻止了默认事件执行,也就是原本将要在控制台打印的错误信息
    };
  2. window.addEventListener('error')。这个方法也可以捕获到 JS运行异常,但它与window.onerror 的区别在于:

  • 它除了可以监听 JS运行异常 之外,还可以同时捕获到 静态资源加载异常
  • 它会比 window.onerror 先触发
  • onerror 可以接受多个参数。而 addEventListener('error') 只有一个保存所有错误信息的参数。

综上所述,笔者决定采用 window.addEventListener('error') 捕获 js 异常:

window.addEventListener('error', (event) => {
    handleJs(event);
}, true)

const handleJs = function (event: any): void {
    event.preventDefault();

    // 用户最后一个交互事件
    const lastEvent: Event = getLastEvent();
    let log = null;

    // 判断报错种类
    const type = getErrorKey(event);

    if (type === mechanismType.JS) {
        log = {
            message: event.message,
            type: event.type,
            errorType: `${mechanismType.JS}: ${(event.error && event.error.name) || 'UnKnowun'}`,
            fileName: event.filename,
            position: `${event.lineno}:${event.colno}`,
            // stack: getLines(event.error.stack), //错误堆栈
            selector: lastEvent ? getSelector((lastEvent as any).path) : '',
        }
        console.log('jsError log数据', log)
    }
}

Promise 异常

当抛出 Promise异常 时,会触发 unhandledrejection 事件,所以我们只需要去监听它就可以进行 Promise 异常 的捕获了:

window.addEventListener('unhandledrejection', (event) => {
    handlePromise(event);
}, true)

const handlePromise = function (event: any): void {
    // 用户最后一个交互事件
    const lastEvent: Event = getLastEvent();
    let message: string = '';
    let filename: string = '';
    let line: number = 0;
    let column: number = 0;
    let stack: string = '';
    let reason = event.reason;
    if (typeof reason === 'string') {
        message = reason;
    } else if (typeof reason === 'object') {
        if (reason.stack) {
            let matchResult = reason.stack.match(/at\s+(.+):(\d+):(\d+)/);
            filename = matchResult[1];
            line = matchResult[2];
            column = matchResult[3];
        }
        message = reason.message;
    }
    const log = {
        message, // 报错信息
        type: event.type,
        errorType: mechanismType.UJ,
        fileName: filename,
        position: `${line}:${column}`,
        selector: lastEvent ? getSelector((lastEvent as any).path) : '',
    }
    console.log('promise log数据', log)
}

不过值得注意的一点是:相比与上面所述的直接获取报错的行号、列号等信息Promise异常 我们只能捕获到一个 报错原因 而已,代码的具体位置我们需要通过 sourceMap 映射源码中的位置才能获得,这里暂未处理。

静态资源加载异常

静态资源加载异常也可以通过 window.addEventListener('error') 捕获,我们只需要在捕获 js 异常的 handleJs 函数体里加上:

if (type === mechanismType.RS) {
    const target = event.target;
    log = {
        type: event.type,
        url: target.src,
        message: `GET ${target.src} net::ERR_CONNECTION_REFUSED`, // TODO
        html: target.outerHTML,
        errorType: mechanismType.RS,
        tagName: target.tagName,
        selector: getSelector(event.path),
    }
    console.log('resourceError log数据', log)
}

console.error 异常

这部分报错我们也是需要捕获的,但却不能向上文那样使用 window.onerrorwindow.addEventListener('error') 直接捕获得到。我们需要重新封装 window.console.error 以捕获异常信息:

let consoleError = window.console.error;
window.console.error = function (error) {
    if (error != '参数有缺失') {
        const message: string = error.message;
        const stack = error.stack;
        const url: string = window.location.href;
        let row: number = 0, column: number = 0;
        if (stack) {
            let mres = stack.match(/(.*?)/g) || [];
            let firstLine = (mres[0] || "").replace("(", "").replace(")", ""); // 获取到堆栈信息的第一条

            // 根据:分隔获取行列
            let info = firstLine.split(':')
            row = info[info.length - 2] // 行
            column = info[info.length - 1] // 列
        }

        setTimeout(function () {
            // 上报的错误内容
            let opt = {
                url,
                row,
                column,
                message,
                stack
            }
            console.log('error捕获', opt);
        }, 0);
    }

    return consoleError.apply(console, arguments as any);
};

跨域异常

window.addEventListener('error')捕获不到跨域错误,反而是被window.addEventListener('unhandledrejection')捕获,这个坑现在暂未解决,后续处理:

跨域异常的坑

暂时使用window.addEventListener('unhandledrejection')捕获跨域错误:

window.addEventListener('unhandledrejection', (event) => {
    handlePromise(event);
}, true)

const handlePromise = function (event: any): void {
    // 使用 isCors 判断捕获到的是跨域错误还是 promise 错误
    const isCors = event.reason instanceof AxiosError;
    if (!isCors) {
        // promise 异常信息,上文已贴
    } else {
        const error = event.reason;

        let { url, method, params, data } = error.config;
        let corsErrorData = {
            errorType: mechanismType.CS,
            type: error.name,
            message: error.message,
            url,
            method,
            status: error.response.status,
            response: error.response || "",
            request: error.request || "",
            params: { query: params, body: data },
        }
        console.log('CORSError log数据', corsErrorData)
    }
}

异常捕获总结

  1. 使用window.addEventListener('error')捕获 JS 运行时错误和资源加载错误,但要区分两者以进行不同操作;

    区分 JS 运行错误或资源加载错误:

    1. event.target && (event.target.src || event.target.href)true
    2. event.target instanceof HTMLScriptElement || event.target instanceof HTMLLinkElement || event.target instanceof HTMLImageElementtrue

    满足以上两者之一代表 资源加载错误 ,否则为 JS 运行错误

  2. 使用window.addEventListener('unhandledrejection')捕获未处理的 promise reject 错误
  3. 重写console.error捕获 console.error 错误

行为监控

路由跳转

遇到的问题

触发一次history.pushState页面就会跳转两次:

路由跳转的坑

性能监控

更新中…


文章作者: hcyety
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hcyety !
评论
  目录