前端数据监控平台

847 阅读10分钟

之前写了一篇文章 作为前端开发,如何调试线上代码?里面就前端线上或许会出现的问题和应对办法都说了一遍,但是还是不过瘾,因为很多时候,客户提出的问题,描述都不够准确。

比如客户说:你们的页面咋白屏了?

开发:具体可以给我说一下,白屏之前你做了什么吗?

客户:我就点了一下按钮

开发:你能告诉我是哪个页面的哪个按钮吗?

最后,客户极其不耐烦的地丢过来一张没有缺少url的白屏给你看,试图证明他说的是真的,真白屏了。

最后不再理睬你!

开发人员试图找到解决方案,想快速解决线上bug,但是客户却以为你在质疑他。

真的有点秀才遇上兵有理说不清,其实真的没有谁对谁错,只是双方立场不同,作为客户,你的系统bug了,我哪里知道我刚才干了啥成这样了?大家都不是专门的测试人员,肯定不知道如何准确的描述和回溯系统问题。

所以诸多场景证明,我们必须要搭建一个监控平台来帮我们记录错误发生的具体情况。而不是跟在客户的屁股后面,把时间浪费在相互沟通上。

前端监控系统主要包含三个方面:错误监控,性能监控。

一.错误监控

错误监控主要包括三个步骤:搜集错误,进行上报,然后对症分析。

任何时候,我要想快速解决问题,一定要问自己几个问题,问题明确之后,相应的解决方案就会自动出现,我最喜欢的就是这 5W1H 思考法。

  1. What,我们遇到了什么问题? 前端页面报错难以追溯,客户无法描述清楚,到底是什么问题。
  2. When,什么时候出现的? 在提供错误信息的时候,最好带上时间戳。
  3. Who,影响了多少用户? 这个错误后面最好带上IP 和 设备信息。
  4. Where,在哪里出现了报错? 最好给他截屏,还要带上url。
  5. Why,为什么报错了?最好能将开发者工具console里面的报错详情发给我,包括错误堆栈、⾏列、SourceMap。
  6. How,我该怎么解决这个问题。

基于上述问题和解决方案,我们搭建一个前端监控平台,项目模型图如下所示:

image.png

1.组成部分

整个应用就包含三个部分:

    1. 给咱们的项目接入监控。
    1. 后端进行数据分析。
    1. 在数据监控平台上显示各个监控平台的报警信息。

备注:本文只介绍接入和数据分析,监控平台不做,监控平台就是个管理系统,有了监控数据,剩下的就是表格加echart展示,自己补全!

2.错误类型

前端出现的错误,我们可以把他分为两类,一类是 页面错误, 如页面异常,导致页面白屏的错误;一类是 网络错误,即由于服务端异常所导致的错误,或者不符合既定前后端约束的错误。

具体的错误,我整理为下面两张图:

image.png

image.png

3.搜集错误

1.try/catch:能捕获常规运行时错误,只捕捉同步错误,不捕捉异步错误。

2.window.onerror

当 JS 运行时错误发生时,window 会触发一个 ErrorEvent 接口的 error 事件。

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

它可以捕获异步错误。

3.window.addEventListener

它能捕获:图片、script、css加载错误。

window.addEventListener('error') 来捕获 JS运行异常;它会比 window.onerror 先触发

window.onerror能做的事情,window.addEventListener('error')也能做,而且他还会监听静态资源的加载错误。

所以window.addEventListener('error')更靠谱

4.react组件错误:react 通过componentDidCatch,声明一个错误边界的组件,它是高阶组件,只需将子组件传入即可错误兜底。

实际上在我们的项目里面,搜集错误直接用下面这个就能全部扫描到:

import getLastEvent from "../utils/getLastEvent";
import getSelector from "../utils/getSelector";
import tracker from "../utils/tracker";
import axios from 'axios';

export function injectJsError() {
  // 监听全局未捕获的错误
  window.addEventListener(
    "error",
    async (event) => {
      let lastEvent = getLastEvent(); // 获取到最后一个交互事件
      // 脚本加载错误
      if (event.target && (event.target.src || event.target.href)) {
        tracker.send({
          kind: "stability", // 监控指标的大类,稳定性
          type: "error", // 小类型,这是一个错误
          errorType: "resourceError", // js执行错误
          filename: event.target.src || event.target.href, // 哪个文件报错了
          tagName: event.target.tagName,
          selector: getSelector(event.target), // 代表最后一个操作的元素
        });
      } else {
        //此时你拿到的数据是打包后的报错信息,所以需要转化
        const { data } = await axios.get(`/getErrorInfo?filepath=${event.filename}&lineno=${event.lineno}&colno=${event.colno}`);
        console.log(data, 99999);

        tracker.send({
          kind: "stability", // 监控指标的大类,稳定性
          type: "error", // 小类型,这是一个错误
          errorType: "jsError", // js执行错误
          message: event.message, // 报错信息
          filename: data.source, // 哪个文件报错了
          position: `${data.line}:${data.column}`, // 报错的行列位置
          budle: data.budle,
          errorName: data.name,
          stack: getLines(event.error.stack),
          selector: lastEvent ? getSelector(lastEvent.path) : "", // 代表最后一个操作的元素
        });
      }
    },
    true
  );

  window.addEventListener(
    "unhandledrejection",
    (event) => {
      console.log("unhandledrejection-------- ", event);
      let lastEvent = getLastEvent(); // 获取到最后一个交互事件
      let message;
      let filename;
      let line = 0;
      let column = 0;
      let stack = "";
      let reason = event.reason;
      if (typeof reason === "string") {
        message = reason;
      } else if (typeof reason === "object") {
        message = reason.message;
        if (reason.stack) {
          let matchResult = reason.stack.match(/at\s+(.+):(\d+):(\d+)/);
          filename = matchResult[1];
          line = matchResult[2];
          column = matchResult[3];
        }
        stack = getLines(reason.stack);
      }
      tracker.send({
        kind: "stability", // 监控指标的大类,稳定性
        type: "error", // 小类型,这是一个错误
        errorType: "promiseError", // js执行错误
        message, // 报错信息
        filename, // 哪个文件报错了
        position: `${line}:${column}`, // 报错的行列位置
        stack,
        selector: lastEvent ? getSelector(lastEvent.path) : "", // 代表最后一个操作的元素
      });
    },
    true
  );
}

function getLines(stack) {
  return stack
    .split("\n")
    .slice(1)
    .map((item) => item.replace(/^\s+at\s+/g, ""))
    .join("^");
}

我们的包里面只用到了2个就能监控到所有的错误

 window.addEventListener("error",()=>{})
 window.addEventListener("unhandledrejection",()=>{})

使用,在main.jsx里面导入他们。

image.png

这样它就会在打包的时候,把我们的搜索错误的两个事件加进去做实时监听。

5.错误上传

我们把错误拿到,还要上报到具体的服务器上,这样才有用对吧

image.png

他就是个上传函数,

image.png

6.打通sourceMap

你拿到错误了,但是线上是这样的,你咋知道到底是那个文件在报错?所以需要打通source-map。

image.png

上面的监控已经能够拿到页面的错误了,但是线上没有.map文件,你咋搞?

6.1. 配置打包工具

将vite.config.js里面的sourcemap配成hidden,这样他会生成map文件但是用户看不到。

image.png

直接用live-server启动dist/index.html模拟线上

image.png

测试发现没有出现map文件

image.png

6.2 上传map文件

开发一个vite插件,要他在执行npm run build 的时候去把map文件存到监控服务器里面去,这样实时监控的时候才能找到具体的map文件,从而映射出来错误的行列。

import fs from 'fs';
import http from 'http';
import path from 'path';

function upload(file) {
  return new Promise((resolve) => {
    let req = http.request(`http://localhost:3002/upload?name=${file}`, {
      method: "POST",
      headers: {
        "Content-Type": "application/octet-stream",
        Connection: "keep-alive",
      },
    });

    let fileStream = fs.createReadStream(file);
    fileStream.pipe(req, { end: false });
    fileStream.on("end", function () {
      req.end();
      resolve();
    });
  });
}

const MyPlugin = () => {
  return {
    name: 'my-plugin',
    async closeBundle() {
      let chunks = fs.readdirSync('./dist/assets');
      let map_file = chunks.filter((item) => {
        return item.match(/\.js\.map$/) !== null;
      });

      while (map_file.length > 0) {
        let file = map_file.shift();
        await upload(path.join('./dist/assets', file));
      }
    },
  };
};

export default MyPlugin;

我们写一个node接口来模拟这个过程

import path from "node:path";
import fs from 'fs';
import express from 'express';
const app = express();
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { SourceMapConsumer } from 'source-map';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

//1. 设置文件存放位置

app.post('/upload', (req, res) => {  // file 与前端input 的name属性一致
  const file = req.query.name;

  if (!file) {
    return;
  }

  const filename = file?.split(`\\`).pop();
  let dir = path?.join(__dirname, "source-map");

  //判断source文件夹是否存在
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir);
  }
  const filePath = path?.join(dir, filename);
  const ws = fs.createWriteStream(filePath);
  req.pipe(ws);
});


app.listen(3002, () => {
  console.log(`已经启动服务,端口号是3002`);
});

测试看看,首先第一步启动服务

node server.js

npm run build

执行完 npm run build 以后你会发现在项目下面出现一个 source-map 的文件夹,里面会把所有的 map 文件都加入进来。

image.png

image.png

6.3 接入source-map

现在有了map文件以后,我们拿着监控到错误,去调个接口去找真实文件对应的错误。

image.png

import path from "node:path";
import fs from 'fs';
import express from 'express';
const app = express();
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { SourceMapConsumer } from 'source-map';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

app.get("/getErrorInfo", async (req, res) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  // 填入错误信息
  console.log(req.query, 99);
  if (!req.query) {
    return;
  }

  const { filepath, lineno, colno } = req.query;

  const filename = filepath.split('/').pop();
  const file = path.join(`${__dirname}/source-map`, `${filename}.map`);
  const rawSourceMap = fs.readFileSync(file, 'utf-8');

  let consumer = await new SourceMapConsumer(rawSourceMap);

  let result = await consumer.originalPositionFor({
    line: parseInt(lineno),
    column: parseInt(colno),
  });

  res.send({
    ...result,
    budle: `${filename}.map`
  });
});

app.listen(3002, () => {
  console.log(`已经启动服务,端口号是3002`);
});

当我们监控到的错误是这样的

image.png

我们就把 index-CqtqrVqd.js:45:4998传入接口/getErrorInfo。然后用工具包:source-map处理。

最终处理后的数据长这样

image.png

我们已经找到了具体的错误在哪里。

二.行为监控

就是监控我们在页面上常见的事件,点击、滚动、输入、等。

下面这是一个行为监控模板。

// 定义一个发送数据的函数
// 这里 后续会改进掉 使用img.src的方式进行数据发送 具体原因也会在后面详细说明
function sendData(data) {
  // 在这里,你可以使用AJAX、Fetch或其他方法将数据发送到服务器
  // 例如:
  // fetch('/api/track', {
  //   method: 'POST',
  //   body: JSON.stringify(data),
  //   headers: {
  //     'Content-Type': 'application/json'
  //   }
  // });
}

// 监听点击事件
document.addEventListener('click', function(event) {
  // 获取点击的元素
  let target = event.target;
  console.log('我点击了~~',event);

  // 获取元素的相关信息,例如ID、类名等
  let id = target.id;
  let className = target.className;

  // 构造要发送的数据
  let data = {
    type: 'click',
    id: id,
    className: className,
    // 其它你想要收集的信息
  };

  // 发送数据
  sendData(data);
});

// 监听滚动事件
document.addEventListener('scroll', function(event) {
  // 获取滚动位置
  let scrollTop = document.documentElement.scrollTop || document.body.scrollTop;

  // 构造要发送的数据
  let data = {
    type: 'scroll',
    scrollTop: scrollTop,
    // 其它你想要收集的信息
  };

  // 发送数据
  sendData(data);
});

// 监听输入事件
document.addEventListener('input', function(event) {
  // 获取输入的元素和值
  let target = event.target;
  let value = target.value;

  // 构造要发送的数据
  let data = {
    type: 'input',
    value: value,
    // 其它你想要收集的信息
  };

  // 发送数据
  sendData(data);
});

不过一般情况下,我们只需要监控长任务就好了,不需要所有的事件都监控,所以我们可以对他们进行封装,结果如下:

import tracker from "../utils/tracker";
import formatTime from "../utils/formatTime";
import getLastEvent from "../utils/getLastEvent";
import getSelector from "../utils/getSelector";

export function longTask() {
  new PerformanceObserver((list) => {
    list.getEntries().forEach((entry) => {
      if (entry.duration > 100) {
        let lastEvent = getLastEvent();
        
        requestIdleCallback(() => {
          tracker.send({
            kind: "experience",
            type: "longTask",
            eventType: lastEvent.type,
            startTime: formatTime(entry.startTime), // 开始时间
            duration: formatTime(entry.duration), // 持续时间
            selector: lastEvent
              ? getSelector(lastEvent.path || lastEvent.target)
              : "",
          });
        });
      }
    });
  }).observe({ entryTypes: ["longtask"] });
}

image.png

//getLastEvent.js
let lastEvent;

["click", "touchstart", "mousedown", "keydown", "mouseover"].forEach(
  (eventType) => {
    document.addEventListener(
      eventType,
      (event) => {
        lastEvent = event;
      },
      {
        capture: true, // 是在捕获阶段还是冒泡阶段执行
        passive: true, // 默认不阻止默认事件
      }
    );
  }
);

export default function () {
  return lastEvent;
}

//getSelector.js
function getSelectors(path) {
  // 反转 + 过滤 + 映射 + 拼接
  return path
    .reverse()
    .filter((element) => {
      return element !== document && element !== window;
    })
    .map((element) => {
      console.log("element", element.nodeName);
      let selector = "";
      if (element.id) {
        return `${element.nodeName.toLowerCase()}#${element.id}`;
      } else if (element.className && typeof element.className === "string") {
        return `${element.nodeName.toLowerCase()}.${element.className}`;
      } else {
        selector = element.nodeName.toLowerCase();
      }
      return selector;
    })
    .join(" ");
}

export default function (pathsOrTarget) {
  if (Array.isArray(pathsOrTarget)) {
    return getSelectors(pathsOrTarget);
  } else {
    let path = [];
    while (pathsOrTarget) {
      path.push(pathsOrTarget);
      pathsOrTarget = pathsOrTarget.parentNode;
    }
    return getSelectors(path);
  }
}

//formatTime.js
export default (time) => new Date(time).getTime();

拿到长任务,我就可以有的放矢的精准命中目标,进行优化了。

三.性能监控

性能监控就是我们想办法拿到性能指标:FP,FCP,FMP,LCP 等等。

在页面加载的时候,就是要监听load事件,我们可以通过window.performance.timing拿到具体的事件,可以用指标之间的加减关系,相互换算得出指标数。当然你可以用web-vitals包直接拿到相关数据,你也可以用PerformanceObserver拿到性能实时数据。

image.png

你也可以看看这篇文章:zhuanlan.zhihu.com/p/62365390

image.png

我们用最土的办法如下:

// 定义一个发送数据的函数
function sendData(data) {
  console.log('我才不要每次都触发呢',data);
  setTimeout(()=> {
    // 在这里,你可以使用AJAX、Fetch或其他方法将数据发送到服务器
    // 例如:
    // fetch('/api/track', {
    //   method: 'POST',
    //   body: JSON.stringify(data),
    //   headers: {
    //     'Content-Type': 'application/json'
    //   }
    // });
  }, 10000)
}

// 监听页面加载事件
window.addEventListener('load', function() {
  // 获取性能数据
  const [performanceData] = performance.getEntriesByType("navigation");
  // 即将废弃 推荐上面的PerformanceNavigationTiming写法
  // let performanceData = window.performance.timing;
  
  // 计算页面加载时间 (window.performance.timing使用这个)
  // let pageLoadTime = performanceData.domContentLoadedEventEnd - performanceData.navigationStart;
  
 // 计算页面加载时间 (performance.getEntriesByType("navigation")的时候使用这个)
     let pageLoadTime = performanceData.loadEventEnd - performanceData.domComplete;

  // 计算请求响应时间
  const requestResponseTime = performanceData.responseEnd - performanceData.requestStart;

  // 计算DNS查询时间
  let dnsLookupTime = performanceData.domainLookupEnd - performanceData.domainLookupStart;

  // 计算TCP连接时间
  let tcpConnectTime = performanceData.connectEnd - performanceData.connectStart;

  // 计算白屏时间 (老的)
   // var whiteScreenTime = performanceData.responseStart - performanceData.navigationStart;
   
   // 计算白屏时间 (当前的)
  var whiteScreenTime = performanceData.domInteractive - performanceData.responseStart;
  
  
  // 获取 FCP 时间
  let fcpTime = 0;
  const [fcpEntry] = performance.getEntriesByName("first-contentful-paint");
  if (fcpEntry) {
    fcpTime = fcpEntry.startTime;
  }

  // 获取 LCP 时间
  let lcpTime = 0;
  const lcpEntries = performance.getEntriesByType("largest-contentful-paint");
  if (lcpEntries.length > 0) {
    lcpTime = lcpEntries[lcpEntries.length - 1].renderTime || lcpEntries[lcpEntries.length - 1].loadTime;
  }
  
  // Paint Timing
  const paintMetrics = performance.getEntriesByType('paint');
  paintMetrics.forEach((metric) => {
    console.log(metric.name + ': ' + metric.startTime + 'ms');
  });
 
    // 监听长任务
    let tti = 0;
    let tbt = 0;
    const observer = new PerformanceObserver((entryList) => {
      for (const entry of entryList.getEntries()) {
        // 计算 TBT
        if (entry.duration > 50) {
          tbt += entry.duration - 50;
        }
      }

      // 计算 TTI
      if (tti === 0 && tbt < 50) {
        tti = performance.now();
      }
    });
    observer.observe({ entryTypes: ["longtask"] });
    
  // 构造要发送的性能数据
  let perfData = {
    type: 'performance',
    pageLoadTime: pageLoadTime,
    dnsLookupTime: dnsLookupTime,
    tcpConnectTime: tcpConnectTime,
    whiteScreenTime: whiteScreenTime,
    requestResponseTime: requestResponseTime,
    tbt:tbt,
    tti:tti
    // 其它你想要收集的信息
  };
  
  

  // 发送性能数据
  sendData(perfData);
  });
});


再封装一下呗

import tracker from "../utils/tracker";
import onload from "../utils/onload";
import formatTime from "../utils/formatTime";
import getLastEvent from "../utils/getLastEvent";
import getSelector from "../utils/getSelector";

export function timing() {
  let FMP, LCP;
  // 增加一个性能条目的观察者
  new PerformanceObserver((entryList, observer) => {
    const perfEntries = entryList.getEntries();
    FMP = perfEntries[0];
    observer.disconnect(); // 不再观察了
  }).observe({ entryTypes: ["element"] }); // 观察页面中有意义的元素
  // 增加一个性能条目的观察者
  new PerformanceObserver((entryList, observer) => {
    const perfEntries = entryList.getEntries();
    const lastEntry = perfEntries[perfEntries.length - 1];
    LCP = lastEntry;
    observer.disconnect(); // 不再观察了
  }).observe({ entryTypes: ["largest-contentful-paint"] }); // 观察页面中最大的元素
  // 增加一个性能条目的观察者
  new PerformanceObserver((entryList, observer) => {
    const lastEvent = getLastEvent();
    const firstInput = entryList.getEntries()[0];
    if (firstInput) {
      // 开始处理的时间 - 开始点击的时间,差值就是处理的延迟
      let inputDelay = firstInput.processingStart - firstInput.startTime;
      let duration = firstInput.duration; // 处理的耗时
      if (inputDelay > 0 || duration > 0) {
        tracker.send({
          kind: "experience", // 用户体验指标
          type: "firstInputDelay", // 首次输入延迟
          inputDelay: inputDelay ? formatTime(inputDelay) : 0, // 延迟的时间
          duration: duration ? formatTime(duration) : 0,
          startTime: firstInput.startTime, // 开始处理的时间
          selector: lastEvent
            ? getSelector(lastEvent.path || lastEvent.target)
            : "",
        });
      }
    }
    observer.disconnect(); // 不再观察了
  }).observe({ type: "first-input", buffered: true }); // 第一次交互

  // 刚开始页面内容为空,等页面渲染完成,再去做判断
  onload(function () {
    setTimeout(() => {
      const {
        fetchStart,
        connectStart,
        connectEnd,
        requestStart,
        responseStart,
        responseEnd,
        domLoading,
        domInteractive,
        domContentLoadedEventStart,
        domContentLoadedEventEnd,
        loadEventStart,
      } = window.performance.timing;
      // 发送时间指标
      tracker.send({
        kind: "experience", // 用户体验指标
        type: "timing", // 统计每个阶段的时间
        connectTime: connectEnd - connectStart, // TCP连接耗时
        ttfbTime: responseStart - requestStart, // 首字节到达时间
        responseTime: responseEnd - responseStart, // response响应耗时
        parseDOMTime: loadEventStart - domLoading, // DOM解析渲染的时间
        domContentLoadedTime:
          domContentLoadedEventEnd - domContentLoadedEventStart, // DOMContentLoaded事件回调耗时
        timeToInteractive: domInteractive - fetchStart, // 首次可交互时间
        loadTime: loadEventStart - fetchStart, // 完整的加载时间
      });
      // 发送性能指标
      let FP = performance.getEntriesByName("first-paint")[0];
      let FCP = performance.getEntriesByName("first-contentful-paint")[0];
      console.log("FP", FP);
      console.log("FCP", FCP);
      console.log("FMP", FMP);
      console.log("LCP", LCP);
      tracker.send({
        kind: "experience",
        type: "paint",
        firstPaint: FP ? formatTime(FP.startTime) : 0,
        firstContentPaint: FCP ? formatTime(FCP.startTime) : 0,
        firstMeaningfulPaint: FMP ? formatTime(FMP.startTime) : 0,
        largestContentfulPaint: LCP
          ? formatTime(LCP.renderTime || LCP.loadTime)
          : 0,
      });
    }, 3000);
  });
}

其实他就是多加了三个观察者而已,本质是没有变化的。

四.网络监控

网速监控

import tracker from "../utils/tracker";

//网络链接时间
export function pv() {
  var connection = navigator.connection;
  tracker.send({
    kind: "business",
    type: "pv",
    effectiveType: connection.effectiveType, //网络环境
    rtt: connection.rtt, //往返时间
    screen: `${window.screen.width}x${window.screen.height}`, //设备分辨率
  });
  let startTime = Date.now();


  window.addEventListener(
    "unload",
    () => {
      let stayTime = Date.now() - startTime;

      tracker.send({
        kind: "business",
        type: "stayTime",
        stayTime,
      });
    },
    false
  );
}

请求监控

参考:blog.csdn.net/aniudunaich…

image.png

import tracker from "../utils/tracker";

export function injectXHR() {
  let XMLHttpRequest = window.XMLHttpRequest;
  let oldOpen = XMLHttpRequest.prototype.open;
  XMLHttpRequest.prototype.open = function (method, url, async) {
    // 把上报接口过滤掉
    if (!url.match(/logstores/) && !url.match(/sockjs/)) {
      this.logData = { method, url, async };
    }
    return oldOpen.apply(this, arguments);
  };
  let oldSend = XMLHttpRequest.prototype.send;

  XMLHttpRequest.prototype.send = function (body) {
    if (this.logData) {
      let startTime = Date.now();
      let handler = (type) => (event) => {
        // 持续时间
        let duration = Date.now() - startTime;
        let status = this.status;
        let statusText = this.statusText;
        tracker.send({
          kind: "stability",
          type: "xhr",
          eventType: type,
          pathname: this.logData.url,
          status: status + "-" + statusText, // 状态码
          duration,
          response: this.response ? JSON.stringify(this.response) : "", // 响应体
          params: body || "", // 入参
        });
      };

      this.addEventListener("load", handler("load"), false);
      this.addEventListener("error", handler, false);
      this.addEventListener("abort", handler, false);
    }
    return oldSend.apply(this, arguments);
  };
}

import tracker from "../utils/tracker";

export function injectFetch() {
  let oldFetch = window.fetch;

  function hijackFetch(url, options) {
    let startTime = Date.now();
    return new Promise((resolve, reject) => {
      oldFetch.apply(this, [url, options]).then(async response => {
        // response 为流数据
        const oldResponseJson = response.__proto__.json;
        response.__proto__.json = function (...responseRest) {
          return new Promise((responseResolve, responseReject) => {
            oldResponseJson.apply(this, responseRest).then(result => {
              responseResolve(result);
            }, (responseRejection) => {
              // 接口
              sendLogData({
                url,
                startTime,
                statusText: response.statusText,
                status: response.status,
                eventType: 'error',
                response: responseRejection.stack,
                options
              });
              responseReject(responseRejection);
            });
          });
        };
        resolve(response);
      }, rejection => {
        // 连接未连接上
        sendLogData({
          url,
          startTime,
          eventType: 'load',
          response: rejection.stack,
          options
        });
        reject(rejection);
      });
    });
  }
  window.fetch = hijackFetch;
}

const sendLogData = ({
  startTime,
  statusText = '',
  status = '',
  eventType,
  url,
  options,
  response,
}) => {
  // 持续时间
  let duration = Date.now() - startTime;
  const { method = 'get', body } = options || {};
  tracker.send({
    kind: "stability",
    type: "fetch",
    eventType: eventType,
    pathname: url,
    status: status + "-" + statusText, // 状态码
    duration,
    response: response ? JSON.stringify(response) : "", // 响应体
    method,
    params: body || "", // 入参
  });
};